Merge branch 'stable-3.10'

* stable-3.10:
  Fix reloading of plugins registering dynamic items
  Revert "Compute diff URLs in the view model"
  Compute diff URLs in the view model
  Test for `true` in if statement for better readability
  Correctly redirect legacy URIs with patchset
  Update Material Icons
  Manage attach of ApiModule binding upon plugin reload
  Update git submodules
  Update delete-project to bd49c1bf4212a166d1246774cb8c70d54ead31ba
  Fix code comment position with Firefox
  dev-bazel: Replace mention of java 11 with java 21

Release-Notes: skip
Change-Id: Ib28ac7eece77fe10866ce57baab403fa15bea3b2
diff --git a/.bazelrc b/.bazelrc
index 480bea7..74427f3 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,6 +1,7 @@
 # TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
 # https://issues.gerritcodereview.com/issues/303819949
 common --noenable_bzlmod
+common --incompatible_enable_proto_toolchain_resolution
 
 build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
@@ -51,10 +52,6 @@
 build:remote21_bb --config=config_bb
 build:remote21_bb --config=build_java21_shared
 
-# Enable modern C++ features
-build --cxxopt=-std=c++17
-build --host_cxxopt=-std=c++17
-
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
 # This will be the new default behavior at some point (and the flag was flipped
diff --git a/.github/pull_request_termplate.md b/.github/pull_request_termplate.md
new file mode 100644
index 0000000..e4b3563
--- /dev/null
+++ b/.github/pull_request_termplate.md
@@ -0,0 +1,14 @@
+Thank you for contributing to Gerrit Code Review!
+
+- Gerrit uses [gerrit-review.googlesource.com](https://gerrit-review.googlesource.com)
+for code changes and review
+- The [gerrit repository on github](https://github.com/GerritCodeReview/gerrit)
+is a read-only mirror
+- Therefore **pull requests in this repository cannot be merged**.
+
+Find documentation how to contribute to Gerrit here
+- [Submitting Patches](../SUBMITTING_PATCHES)
+- [How to Contribute](https://gerrit-review.googlesource.com/Documentation/dev-community.html#how-to-contribute)
+- [Crafting Changes](https://gerrit-review.googlesource.com/Documentation/dev-crafting-changes.html)
+- [Developer Setup](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
+- [Gerrit Code Review Workflow](https://gerrit-review.googlesource.com/Documentation/intro-user.html#code-review)
diff --git a/.gitignore b/.gitignore
index 0bbcaba..bc489bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@
 /.classpath
 /.factorypath
 /.idea
+/.aswb
 /.ijwb
 /.metadata
 /.project
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 2e90cff..7d531ff 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -97,10 +97,11 @@
 groups are created on Gerrit site initialization and unique UUIDs are assigned
 to those groups. These UUIDs are different on different Gerrit sites.
 
-Gerrit comes with two predefined groups:
+Gerrit comes with three predefined groups:
 
-* Administrators
-* Service Users
+* link:#administrators[Administrators]
+* link:#service_users[Service Users]
+* link:#blocked_users[Blocked Users]
 
 
 [[administrators]]
@@ -138,6 +139,15 @@
 
 Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'.
 
+[[blocked_users]]
+=== Blocked Users
+
+This is a predefined group, created on Gerrit site initialization, for which
+the link:#category_read[Read] access right is globally blocked.
+
+link:#administrators[Administrators] can add spammers to this group in order to
+block them from accessing Gerrit so that they cannot post any further spam.
+
 == Account Groups
 
 Account groups contain a list of zero or more user account members,
@@ -834,11 +844,16 @@
 
 Users without this access right can still remove their own votes.
 
+Note, removing votes is generally disallowed for merged changes as this could
+remove approvals that were necessary for the submission and it's confusing to
+see a merged change which doesn't have the necessary approvals to fulfill the
+submit requirements.
+
 [[category_remove_reviewer]]
 === Remove Reviewer
 
 This category permits users to remove other users from the list of
-reviewers on a change.
+reviewers on a change, including their votes.
 
 Change owners can always remove reviewers who have given a zero or positive
 score (even without having the `Remove Reviewer` access right assigned).
@@ -849,6 +864,11 @@
 Users without this access right can only remove themselves from the
 reviewer list on a change.
 
+Note, removing reviewers with non-zero votes is generally disallowed for merged
+changes as this could remove approvals that were necessary for the submission
+and it's confusing to see a merged change which doesn't have the necessary
+approvals to fulfill the submit requirements.
+
 
 [[category_review_labels]]
 === Review Labels
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index c54181b..72647cb 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1719,7 +1719,7 @@
 With a configured 30 second delay a server with 4900 active users will
 typically need to dedicate 1 CPU to the update check.  4900 users
 divided by an average delay of 30 seconds is 163 requests arriving per
-second.  If requests are served at \~6 ms response time, 1 CPU is
+second.  If requests are served at ~6 ms response time, 1 CPU is
 necessary to keep up with the update request traffic.  On a smaller
 user base of 500 active users, the default 30 second delay is only 17
 requests per second and requires ~10% CPU.
@@ -2741,6 +2741,13 @@
 +
 By default empty.
 
+[[gerrit.requireChangeForConfigUpdate]]gerrit.requireChangeForConfigUpdate::
++
+If true, all attempts to update a project config directly using any REST API are rejected.
+Instead, users should always use APIs which create a config change (for review).
++
+By default `false`.
+
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index bcc96b4..4fa5e68 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -623,6 +623,105 @@
     .to(MyListener.class);
 ----
 
+Blocking inside onStop() is a good choice for QOS limits which are
+attempting to restrict total usage of resources as might be done to
+to prevent a server overload. In these cases, when a server's resources
+are being exhausted, it is important to throttle all `Tasks`, and blocking
+the current thread from being used by any Task makes sense. However,
+Task parking (see below) is more appropriate if it desirable to limit a
+specific resource usage in favor of other resources, such as when
+prioritization or fairness policies are desired.
+
+[[taskParker]]
+== TaskParkers
+
+It is possible to park `com.google.gerrit.server.git.WorkQueue$Task`s
+before they run without depriving other `Tasks` of a thread. Parking is
+particularly useful for (de-)prioritizing certain `Tasks` based on resource
+quotas without blocking `Tasks` not using those resources. For example,
+when there is a desire to limit how many commands a single user can run
+concurrently it is typically also desirable to not limit the total amount
+of concurrently running commands to the same limit. The Task parking
+mechanism is useful for such Task limiting scenarios.
+
+The `TaskParker` interface works well with a Semaphore's `tryAcquire()`
+method even when the Semaphore is unavailable. However, blocking should
+not be done with a `TaskParker` and if it is desired, such as when using a
+Semaphore's `acquire()` method, use a `TaskListener` interface instead.
+
+To make use of Task parking, implement a
+`com.google.gerrit.server.git.WorkQueue$TaskParker` and register the
+TaskParker (as a TaskListener) from a plugin like this:
+
+[source,java]
+----
+  public class MyParker implements TaskParker {
+    Semaphore semaphore = new Semaphore(3);
+
+    @Override
+    public boolean isReadyToStart(Task<?> task) {
+      try {
+        return semaphore.tryAcquire();
+      } catch (InterruptedException e) {
+        return false;
+      }
+    }
+
+    @Override
+    public void onNotReadyToStart(Task<?> task) {
+      semaphore.release();
+    }
+
+    @Override
+    public void onStart(Task<?> task) {}
+
+    @Override
+    public void onStop(Task<?> task) {
+      semaphore.release();
+    }
+  }
+
+  bind(TaskListener.class)
+      .annotatedWith(Exports.named("MyParker"))
+      .to(MyParker.class);
+----
+
+Before running a Task, the executor will query each `TaskParker` to see
+if the Task may be run by calling `isReadyToStart()`. If any `TaskParker`
+returns `false` from `isReadyToStart()`, then the Task will get parked
+and the executor will wait until another Task completes before
+attempting to run a parked task again.
+
+Since parked `Tasks` are not actually running and consuming resources,
+they generally should also not be contributing towards those resource
+quotas which caused the task to be parked. For this reason, once it is
+determined that a Task will be parked, the executor will call
+`onNotReadyToStart()` on every `TaskParker` that previously returned `true`
+from `isReadyToStart()`. This allows those TaskParkers to reduce their
+resource usage counts which they bumped up in `isReadyToStart()` with
+the expectation that the Task may run. Since the Task is not running and
+the resource is not being used, reducing the resource usage count allows
+other `Tasks` needing that resource to run while the Task is parked.
+
+Once a running Task completes, the executor will attempt to run
+parked `Tasks` (in the order in which they were parked) by again calling
+`isReadyToStart()` on the TaskParkers, even the TaskParkers which
+previously returned a `true` before the Task was parked. This is
+necessary because although a Task may not have exceeded a specific
+resource limit before it was parked, another Task may since have been
+allowed to run and its usage of that resource may now cause the parked
+task under evaluation to need to be throttled and parked again.
+
+Note, the reason that it is important to not block inside the
+`isReadyToStart()` method is to avoid delaying the executor from calling
+`onNotReadyToStart()` on other TaskParkers holding resources, as this
+would prevent them from freeing those resources. Also, just as it is
+important to later release any resources acquired within
+`isReadyToStart()` in `onStop()`, it is even more important to release
+those resources in `onNotReadyToStart()` since `isReadyToStart()` may
+be called many times per `TaskParker`, but `onStop()` will only ever be
+be called once.
+
 [[change-message-modifier]]
 == Change Message Modifier
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index c2a1f86..2e87150 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -385,7 +385,7 @@
 == Escalation channel to Google
 
 If anything urgent is blocking that requires the attention of a Googler you may
-escalate this by writing an email to Han-Wen Nienhuys: hanwen@google.com
+escalate this by writing an email to Chris Poucet: poucet@google.com
 
 [[deprecating-features]]
 == Deprecating features
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 8b6049e..ab77d1b 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1137,6 +1137,45 @@
 ----
 
 
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[highlightjs-structured-text]]
 highlightjs-structured-text
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 7e3fa9a..91b6227 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -4021,6 +4021,45 @@
 ----
 
 
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[highlightjs-structured-text]]
 highlightjs-structured-text
 
diff --git a/Documentation/pgm-passwd.txt b/Documentation/pgm-passwd.txt
index 133fb03..02c87e5 100644
--- a/Documentation/pgm-passwd.txt
+++ b/Documentation/pgm-passwd.txt
@@ -27,9 +27,9 @@
 
 == ARGUMENTS
 
-SECTION.KEY::
-	Section and key in the `secure.config` file for setting or editing the
-	password value.
+SECTION.[SUBSECTION.]KEY::
+	Section, subsection and key separated by a dot of the
+	password to set. Subsection is optional.
 
 PASSWORD::
 	New password to set in `secure.config` associated to the section and key.
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 991f36c..2e1bc43 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -415,6 +415,9 @@
 Links to the history of the configuration file governing this project's access
 rights as list of link:rest-api-changes.html#web-link-info[WebLinkInfo]
 entities.
+|`require_change_for_config_update`       |not set if `false`|
+Whether the calling user must create a change for updating project config.
+If true, all API requests which directly update project config are rejected.
 |==================================
 
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index d0c4553..384585f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1317,6 +1317,7 @@
     "work_in_progress_by_default": true,
     "allow_browser_notifications": true,
     "allow_suggest_code_while_commenting": true,
+    "allow_autocompleting_comments": true,
     "diff_page_sidebar": "plugin-foo",
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
@@ -1372,6 +1373,7 @@
     "disable_token_highlighting": true,
     "allow_browser_notifications": false,
     "allow_suggest_code_while_commenting": false,
+    "allow_autocompleting_comments": false,
     "diff_page_sidebar": "NONE",
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
@@ -2723,6 +2725,9 @@
 |`allow_suggest_code_while_commenting`  |not set if `false`|
 Whether to receive suggested code while writing comments. This feature needs
 a plugin implementation.
+|`allow_autocompleting_comments`  |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
 |`diff_page_sidebar`            |optional|
 String indicating which sidebar should be open on the diff page. Set to "NONE"
 if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
@@ -2801,6 +2806,9 @@
 |`allow_suggest_code_while_commenting`  |not set if `false`|
 Whether to receive suggested code while writing comments. This feature needs
 a plugin implementation.
+|`allow_autocompleting_comments`  |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
 |`diff_page_sidebar`            |optional|
 String indicating which sidebar should be open on the diff page. Set to "NONE"
 if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4ed05bd..6e14415 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3842,6 +3842,9 @@
 
 Adds one user or all members of one group as reviewer to the change.
 
+NOTE: Adding multiple reviewers at once is possible via the
+link:#set-review[Set Review] REST endpoint.
+
 The reviewer to be added to the change must be provided in the request
 body as a link:#reviewer-input[ReviewerInput] entity.
 
@@ -4081,9 +4084,16 @@
 If another user removed a user's vote, the user with the deleted vote will be
 added to the attention set.
 
+Note, removing votes is generally disallowed for merged changes as this could
+remove approvals that were necessary for the submission and it's confusing to
+see a merged change which doesn't have the necessary approvals to fulfill the
+submit requirements.
+
 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.
+ * '409 Conflict' when the change is merged and hence deleting votes is not
+   allowed
 
 .Request
 ----
@@ -6975,6 +6985,8 @@
 |=================================
 |Field Name          ||Description
 |`patch`             |required|
+|`allow_conflicts`    |optional|
+If true, tolerate conflicts and add conflict markers where 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.
@@ -7214,12 +7226,10 @@
 Number of inserted lines.
 |`deletions`          ||
 Number of deleted lines.
-|`total_comment_count`  |optional|
-Total number of inline comments across all patch sets. Not set if the current
-change index doesn't have the data.
-|`unresolved_comment_count`  |optional|
-Number of unresolved inline comment threads across all patch sets. Not set if
-the current change index doesn't have the data.
+|`total_comment_count`  ||
+Total number of inline comments across all patch sets.
+|`unresolved_comment_count`  ||
+Number of unresolved inline comment threads across all patch sets.
 |`_number`            ||
 The change number. (The underscore is just a relict of a prior
 attempt to deprecate the change number.)
@@ -7348,9 +7358,10 @@
 Only set if this change info is returned in response to a request that
 creates a new change or patch set and conflicts are allowed. In
 particular this field is only populated if the change info is returned
-by one of the following REST endpoints: link:#create-change[Create
-Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
-For Change], link:#cherry-pick[Cherry Pick Revision],
+by one of the following REST endpoints:link:#apply-patch[Apply Patch],
+link:#create-change[Create Change],
+link:#create-merge-patch-set-for-change[Create Merge Patch Set For Change],
+link:#cherry-pick[Cherry Pick Revision],
 link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
 link:#rebase-change[Rebase Change]
 |==================================
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 37121350..a963edb 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -164,6 +164,27 @@
   }
 ----
 
+[[account-deactivation]]
+=== AccountDeactivation
+--
+'POST /config/server/deactivate.stale.accounts'
+--
+Queues the link:config-gerrit.html#accountDeactivation[account deactivator] task.
+
+.Request
+----
+  POST /config/server/deactivate.stale.accounts HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Account deactivator task added to work queue."
+----
+
 [[check-consistency]]
 === Check Consistency
 --
@@ -1749,6 +1770,33 @@
   )]}'
 ----
 
+[[cleanup.changes]]
+=== Cleanup of stale changes
+
+This endpoint allows Gerrit administrators to abandon changes older than some given
+time. This allows to run change cleanup manually outside of the configured schedule or
+if change cleanup has been deactivated.
+
+The change cleanup will run asynchronously.
+
+.Request
+----
+  POST /config/server/cleanup.changes HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "after": "3 months",
+    "if_mergeable": true,
+    "message": "Abandoning stale changes."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Disposition: attachment
+----
+
 [[experiment-endpoints]]
 == Experiment Endpoints
 
@@ -2616,6 +2664,22 @@
 name of the user is not set.
 |====================================
 
+[[clean-changes-input]]
+=== CleanChanges.Input
+The `CleanChanges.Input` entity is being used to configure a run
+of change cleanup.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name||Description
+|`after`||Abandon all changes that weren't updated in the
+timespan given here
+|`if_mergeable`|default: `false`|Whether to also abandon changes
+that are mergeable
+|`message`|optional|Message to post to changes abandoned by the
+cleanup
+|=============================
+
 
 GERRIT
 ------
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index b0e1b49..b9fa35a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -984,6 +984,68 @@
   }
 ----
 
+[[create-config-change]]
+=== Create Config Change for review.
+--
+'PUT /projects/link:#project-name[\{project-name\}]/config:review'
+--
+
+Sets the configuration of a project.
+
+This takes the same input as link:#set-config[Set Config], but creates a pending
+change for review. Like link:#create-change[Create Change], it returns
+a link:#change-info[ChangeInfo] entity describing the resulting change.
+
+.Request
+----
+  PUT /projects/myproject/config:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "description": "demo project",
+    "use_contributor_agreements": "FALSE",
+    "use_content_merge": "INHERIT",
+    "use_signed_off_by": "INHERIT",
+    "create_new_change_for_all_not_in_target": "INHERIT",
+    "enable_signed_push": "INHERIT",
+    "require_signed_push": "INHERIT",
+    "reject_implicit_merges": "INHERIT",
+    "require_change_id": "TRUE",
+    "max_object_size_limit": "10m",
+    "submit_type": "REBASE_IF_NECESSARY",
+    "state": "ACTIVE"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
 [[run-gc]]
 === Run GC
 --
@@ -2514,12 +2576,12 @@
   ]
 ----
 
-SortBy(sortby)::
+SortBy(sort-by)::
 Sort the returned tags by one of the supported sort options: ref (default), creation_time.
 +
 .Request
 ----
-  GET /projects/work%2Fmy-project/tags?sortby=creation_time HTTP/1.0
+  GET /projects/work%2Fmy-project/tags?sort-by=creation_time HTTP/1.0
 ----
 +
 .Response
@@ -3586,6 +3648,82 @@
   HTTP/1.1 200 OK
 ----
 
+[[create-labels-change]]
+=== Create Labels Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/labels:review'
+--
+
+Creates/updates/deletes multiple label definitions in this project at once.
+
+This takes the same input as link:#batch-update-labels[Batch Updates Labels],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+  POST /projects/testproj/config:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Labels",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ],
+    "create": [
+      {
+        "name": "Foo-Review",
+        "values": {
+          " 0": "No score",
+          "-1": "I would prefer this is not submitted as is",
+          "-2": "This shall not be submitted",
+          "+1": "Looks good to me, but someone else must approve",
+          "+2": "Looks good to me, approved"
+      }
+    ],
+    "update:" {
+      "Bar-Review": {
+        "function": "MaxWithBlock"
+      },
+      "Baz-Review": {
+        "copy_condition": "is:MIN"
+      }
+    }
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
+
 [[submit-requirement-endpoints]]
 == Submit Requirement Endpoints
 
@@ -3779,6 +3917,103 @@
   HTTP/1.1 204 No Content
 ----
 
+[[batch-update-submit-requirements]]
+=== Batch Update Submit Requirements
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements/'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-submit-requirement-input[BatchSubmitRequirementInput] entity.
+
+The updates are processed in the following order:
+
+1. submit requirements deletions
+2. submit requirements creations
+3. submit requirements updates
+
+.Request
+----
+  POST /projects/My-Project/submit_requirements/ HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Submit Requirements",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ]
+  }
+----
+
+If the submit requirements updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[create-submit-requirements-change]]
+=== Create Submit Requirements Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements:review'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+This takes the same input as link:#batch-update-submit-requirements[Batch Update Submit Requirements],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+  POST /projects/testproj/submit_requirements:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Submit Requirements",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
 [[ids]]
 == IDs
 
@@ -4171,11 +4406,13 @@
 Whether empty commits should be rejected when a change is merged.
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
-|commentlinks                              |optional|
+|`commentlinks`                              |optional|
 Map of commentlink names to link:#commentlink-input[CommentLinkInput]
 entities to add or update on the project. If the given commentlink
 already exists, it will be updated with the given values, otherwise
 it will be created. If the value is null, that entry is deleted.
+|`message`           |optional|
+A commit message for this change.
 |======================================================
 
 [[config-parameter-info]]
@@ -4521,6 +4758,27 @@
 entities that describe the updates that should be done for the labels.
 |=============================
 
+[[batch-submit-requirement-input]]
+=== BatchSubmitRequirementInput
+The `BatchSubmitRequirementInput` entity contains information for batch updating submit requirements
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the submit requirements updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete`        |optional|
+List of submit requirements that should be deleted.
+|`create`        |optional|
+List of link:#submit-requirement-input[SubmitRequirementInput] entities that
+describe submit requirements that should be created.
+|`update`        |optional|
+Map of label names to link:#submit-requirement-input[SubmitRequirementInput]
+entities that describe the updates that should be done for the submit requirements.
+|=============================
+
 [[project-access-input]]
 === ProjectAccessInput
 The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 96a6d32..7dc070c 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -75,6 +75,7 @@
 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.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -1550,6 +1551,17 @@
     assertThat(res).isEqualTo(expectedContent);
   }
 
+  protected void assertLastCommitAuthorAndShortMessage(
+      String refName, String expectedAuthor, String expectedShortMessage) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref exactRef = repo.exactRef(refName);
+      RevCommit revCommit = rw.parseCommit(exactRef.getObjectId());
+      assertThat(revCommit.getAuthorIdent().getName()).isEqualTo(expectedAuthor);
+      assertThat(revCommit.getShortMessage()).isEqualTo(expectedShortMessage);
+    }
+  }
+
   @CanIgnoreReturnValue
   protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
       throws Exception {
@@ -1576,6 +1588,18 @@
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
 
+  /** Creates a submit requirement with all required field. */
+  protected void configSubmitRequirement(Project.NameKey project, String name) throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName(name)
+            .setAllowOverrideInChildProjects(true)
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+            .build());
+  }
+
   protected void configSubmitRequirement(
       Project.NameKey project, SubmitRequirement submitRequirement) throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 53c952e..9d8674f 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest.TestTicker;
 import com.google.gerrit.acceptance.FakeGroupAuditService.FakeGroupAuditServiceModule;
+import com.google.gerrit.acceptance.GrantDirectPushPermissionsOnStartup.GrantDirectPushPermissionsOnStartupModule;
 import com.google.gerrit.acceptance.ReindexGroupsAtStartup.ReindexGroupsAtStartupModule;
 import com.google.gerrit.acceptance.ReindexProjectsAtStartup.ReindexProjectsAtStartupModule;
 import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
@@ -529,7 +530,9 @@
             },
             new ConfigExperimentFeaturesModule()));
     daemon.addAdditionalSysModuleForTesting(
-        new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
+        new GrantDirectPushPermissionsOnStartupModule(),
+        new ReindexProjectsAtStartupModule(),
+        new ReindexGroupsAtStartupModule());
     daemon.start();
     return new GerritServer(desc, null, createTestInjector(daemon), Optional.of(daemon), null);
   }
@@ -554,7 +557,9 @@
       throws Exception {
     requireNonNull(site);
     daemon.addAdditionalSysModuleForTesting(
-        new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
+        new GrantDirectPushPermissionsOnStartupModule(),
+        new ReindexProjectsAtStartupModule(),
+        new ReindexGroupsAtStartupModule());
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
     String[] args =
         Stream.concat(
diff --git a/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java b/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java
new file mode 100644
index 0000000..fcf4635
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class GrantDirectPushPermissionsOnStartup implements LifecycleListener {
+  public static class GrantDirectPushPermissionsOnStartupModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(GrantDirectPushPermissionsOnStartup.class).in(Scopes.SINGLETON);
+    }
+  }
+
+  private final AllProjectsName allProjects;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final Groups groups;
+
+  @Inject
+  GrantDirectPushPermissionsOnStartup(
+      AllProjectsName allProjects,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      Groups groups) {
+    this.allProjects = allProjects;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.groups = groups;
+  }
+
+  @Override
+  public void start() {
+    try (RefUpdateContext ctx = openTestRefUpdateContext();
+        MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(allProjects)) {
+      ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+      GroupReference adminGroupRef = findAdminGroup().orElseThrow();
+      adminGroupRef = projectConfig.resolve(adminGroupRef);
+      PermissionRule.Builder rule = PermissionRule.builder(adminGroupRef).setAction(Action.ALLOW);
+      projectConfig.upsertAccessSection(
+          RefNames.REFS_HEADS + "*", as -> as.upsertPermission(Permission.PUSH).add(rule));
+      projectConfig.upsertAccessSection(
+          RefNames.REFS_CONFIG, as -> as.upsertPermission(Permission.PUSH).add(rule));
+      projectConfig.commit(metaDataUpdate);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new IllegalStateException(
+          "Unable to assign direct push permissions, tests may fail", e);
+    }
+  }
+
+  @Override
+  public void stop() {}
+
+  private Optional<GroupReference> findAdminGroup() throws IOException, ConfigInvalidException {
+    for (GroupReference groupRef : groups.getAllGroupReferences().collect(toImmutableList())) {
+      InternalGroup group = groups.getGroup(groupRef.getUUID()).orElseThrow();
+      if (group.getName().equals("Administrators")) {
+        return Optional.of(groupRef);
+      }
+    }
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/TestConfigRule.java b/java/com/google/gerrit/acceptance/TestConfigRule.java
index a7f051a..e2ae416 100644
--- a/java/com/google/gerrit/acceptance/TestConfigRule.java
+++ b/java/com/google/gerrit/acceptance/TestConfigRule.java
@@ -47,8 +47,11 @@
       @Override
       public void evaluate() throws Throwable {
         setTestConfigFromDescription(description);
-        statement.evaluate();
-        clear();
+        try {
+          statement.evaluate();
+        } finally {
+          clear();
+        }
       }
     };
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 5b5895f..bdda27a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -28,12 +28,12 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
-import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
@@ -73,7 +73,7 @@
 
   protected Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
-    Consumer<AccountDelta.Builder> accountCreation =
+    ConfigureStatelessDelta accountCreation =
         deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
     AccountState createdAccount =
         accountsUpdate.insert("Create Test Account", accountId, accountCreation);
@@ -234,7 +234,7 @@
 
       if (testAccountInvalidation.preferredEmailWithoutExternalId().isPresent()) {
         updateAccount(
-            (account, deltaBuilder) ->
+            (unusedState, deltaBuilder) ->
                 deltaBuilder.setPreferredEmail(
                     testAccountInvalidation.preferredEmailWithoutExternalId().get()));
       }
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 8f85311..ff43bbd 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -3,6 +3,7 @@
 ANNOTATIONS = [
     "Nullable.java",
     "UsedAt.java",
+    "ConvertibleToProto.java",
 ]
 
 java_library(
diff --git a/java/com/google/gerrit/common/ConvertibleToProto.java b/java/com/google/gerrit/common/ConvertibleToProto.java
new file mode 100644
index 0000000..65074d4
--- /dev/null
+++ b/java/com/google/gerrit/common/ConvertibleToProto.java
@@ -0,0 +1,12 @@
+package com.google.gerrit.common;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/** An annotation used to mark Java entities that have equivalent proto representations. */
+@Retention(RUNTIME)
+@Target({TYPE})
+public @interface ConvertibleToProto {}
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index efbac97..4b7694a 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,6 +22,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.primitives.Ints;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import java.time.Instant;
@@ -58,6 +59,7 @@
 
   /** Key local to Gerrit to identify a user. */
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Id implements Comparable<Id> {
     /** Parse an Account.Id out of a string representation. */
     public static Optional<Id> tryParse(String str) {
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 3ad7e03..51585f3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gson.Gson;
@@ -104,6 +105,7 @@
 
   /** The numeric change ID */
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Id {
     /**
      * Parse a Change.Id out of a string representation.
@@ -271,6 +273,7 @@
    * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
    */
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Key {
     // TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
     // Ideally the standard key() factory method would enforce the format and throw IAE.
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index dea070f..c8fc7d2 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import java.time.Instant;
 import java.util.Objects;
@@ -34,6 +35,7 @@
   }
 
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Key {
     public abstract Change.Id changeId();
 
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 1e48f11..325bd6c 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.gerrit.common.ConvertibleToProto;
 import java.time.Instant;
 import java.util.Objects;
 
@@ -26,6 +27,7 @@
  *
  * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
  */
+@ConvertibleToProto
 public class HumanComment extends Comment {
 
   public boolean unresolved;
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 2426818..e3b3024 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
 
 @AutoValue
+@ConvertibleToProto
 public abstract class LabelId {
   public static final String LEGACY_SUBMIT_NAME = "SUBM";
   public static final String CODE_REVIEW = "Code-Review";
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
index d3123c4..42baebc 100644
--- a/java/com/google/gerrit/entities/NotifyConfig.java
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import java.util.EnumSet;
 import java.util.Set;
@@ -31,6 +32,7 @@
     BCC
   }
 
+  @ConvertibleToProto
   public enum NotifyType {
     // sort by name, except 'ALL' which should stay last
     ABANDONED_CHANGES,
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index e8759fa..6f71874 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.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import java.time.Instant;
 import java.util.List;
@@ -31,6 +32,7 @@
 
 /** A single revision of a {@link Change}. */
 @AutoValue
+@ConvertibleToProto
 public abstract class PatchSet {
   /** Is the reference name a change reference? */
   public static boolean isChangeRef(String name) {
@@ -67,6 +69,7 @@
   }
 
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Id implements Comparable<Id> {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index 608cf0d..f78167b 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.time.Instant;
 import java.util.Optional;
 
@@ -27,6 +28,7 @@
   }
 
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Key {
     public abstract PatchSet.Id patchSetId();
 
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 9c2866c..7b02597 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
@@ -57,6 +58,7 @@
    * <p>This class is immutable and thread safe.
    */
   @Immutable
+  @ConvertibleToProto
   public static class NameKey implements Serializable, Comparable<NameKey> {
     private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
index 1e846fb..6106090 100644
--- a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
+public enum AccountIdProtoConverter implements SafeProtoConverter<Entities.Account_Id, Account.Id> {
   INSTANCE;
 
   @Override
@@ -37,4 +37,14 @@
   public Parser<Entities.Account_Id> getParser() {
     return Entities.Account_Id.parser();
   }
+
+  @Override
+  public Class<Entities.Account_Id> getProtoClass() {
+    return Entities.Account_Id.class;
+  }
+
+  @Override
+  public Class<Account.Id> getEntityClass() {
+    return Account.Id.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
index fc862b0..0634671 100644
--- a/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
@@ -36,6 +36,9 @@
     if (applyPatchInput.patch != null) {
       builder.setPatch(applyPatchInput.patch);
     }
+    if (applyPatchInput.allowConflicts != null) {
+      builder.setAllowConflicts(applyPatchInput.allowConflicts);
+    }
     return builder.build();
   }
 
@@ -45,6 +48,9 @@
     if (proto.hasPatch()) {
       applyPatchInput.patch = proto.getPatch();
     }
+    if (proto.hasAllowConflicts()) {
+      applyPatchInput.allowConflicts = proto.getAllowConflicts();
+    }
     return applyPatchInput;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
index 0d4ec70..909b4d3 100644
--- a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
@@ -17,10 +17,11 @@
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Id;
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
+public enum ChangeIdProtoConverter implements SafeProtoConverter<Entities.Change_Id, Change.Id> {
   INSTANCE;
 
   @Override
@@ -37,4 +38,14 @@
   public Parser<Entities.Change_Id> getParser() {
     return Entities.Change_Id.parser();
   }
+
+  @Override
+  public Class<Change_Id> getProtoClass() {
+    return Change_Id.class;
+  }
+
+  @Override
+  public Class<Change.Id> getEntityClass() {
+    return Change.Id.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
index f3ccdfa..0620c70 100644
--- a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
@@ -17,10 +17,11 @@
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Key;
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
+public enum ChangeKeyProtoConverter implements SafeProtoConverter<Entities.Change_Key, Change.Key> {
   INSTANCE;
 
   @Override
@@ -37,4 +38,14 @@
   public Parser<Entities.Change_Key> getParser() {
     return Entities.Change_Key.parser();
   }
+
+  @Override
+  public Class<Change_Key> getProtoClass() {
+    return Change_Key.class;
+  }
+
+  @Override
+  public Class<Change.Key> getEntityClass() {
+    return Change.Key.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
index 3e93c5a..a76ab98 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
@@ -18,11 +18,12 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.ChangeMessage_Key;
 import com.google.protobuf.Parser;
 
 @Immutable
 public enum ChangeMessageKeyProtoConverter
-    implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
+    implements SafeProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -45,4 +46,14 @@
   public Parser<Entities.ChangeMessage_Key> getParser() {
     return Entities.ChangeMessage_Key.parser();
   }
+
+  @Override
+  public Class<ChangeMessage_Key> getProtoClass() {
+    return ChangeMessage_Key.class;
+  }
+
+  @Override
+  public Class<ChangeMessage.Key> getEntityClass() {
+    return ChangeMessage.Key.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
index 6e8c907..316a042 100644
--- a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -35,7 +35,7 @@
  */
 @Immutable
 public enum HumanCommentProtoConverter
-    implements ProtoConverter<Entities.HumanComment, HumanComment> {
+    implements SafeProtoConverter<Entities.HumanComment, HumanComment> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
@@ -142,4 +142,14 @@
   public Parser<Entities.HumanComment> getParser() {
     return Entities.HumanComment.parser();
   }
+
+  @Override
+  public Class<Entities.HumanComment> getProtoClass() {
+    return Entities.HumanComment.class;
+  }
+
+  @Override
+  public Class<HumanComment> getEntityClass() {
+    return HumanComment.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
index a1894ac..e6e1be7f 100644
--- a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
+public enum LabelIdProtoConverter implements SafeProtoConverter<Entities.LabelId, LabelId> {
   INSTANCE;
 
   @Override
@@ -37,4 +37,14 @@
   public Parser<Entities.LabelId> getParser() {
     return Entities.LabelId.parser();
   }
+
+  @Override
+  public Class<Entities.LabelId> getProtoClass() {
+    return Entities.LabelId.class;
+  }
+
+  @Override
+  public Class<LabelId> getEntityClass() {
+    return LabelId.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
index 201dd78..fc963df 100644
--- a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
@@ -26,7 +26,8 @@
  * com.google.gerrit.proto.Entities.NotifyInfo}.
  */
 @Immutable
-public enum NotifyInfoProtoConverter implements ProtoConverter<Entities.NotifyInfo, NotifyInfo> {
+public enum NotifyInfoProtoConverter
+    implements SafeProtoConverter<Entities.NotifyInfo, NotifyInfo> {
   INSTANCE;
 
   @Override
@@ -47,4 +48,14 @@
   public Parser<Entities.NotifyInfo> getParser() {
     return Entities.NotifyInfo.parser();
   }
+
+  @Override
+  public Class<Entities.NotifyInfo> getProtoClass() {
+    return Entities.NotifyInfo.class;
+  }
+
+  @Override
+  public Class<NotifyInfo> getEntityClass() {
+    return NotifyInfo.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
index c7d1714..3ea14e6 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
@@ -20,11 +20,12 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSetApproval_Key;
 import com.google.protobuf.Parser;
 
 @Immutable
 public enum PatchSetApprovalKeyProtoConverter
-    implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
+    implements SafeProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
   INSTANCE;
 
   private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -55,4 +56,14 @@
   public Parser<Entities.PatchSetApproval_Key> getParser() {
     return Entities.PatchSetApproval_Key.parser();
   }
+
+  @Override
+  public Class<PatchSetApproval_Key> getProtoClass() {
+    return PatchSetApproval_Key.class;
+  }
+
+  @Override
+  public Class<PatchSetApproval.Key> getEntityClass() {
+    return PatchSetApproval.Key.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
index 60c13f1..f6671cf 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
@@ -18,10 +18,12 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSet_Id;
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
+public enum PatchSetIdProtoConverter
+    implements SafeProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -44,4 +46,14 @@
   public Parser<Entities.PatchSet_Id> getParser() {
     return Entities.PatchSet_Id.parser();
   }
+
+  @Override
+  public Class<PatchSet_Id> getProtoClass() {
+    return PatchSet_Id.class;
+  }
+
+  @Override
+  public Class<PatchSet.Id> getEntityClass() {
+    return PatchSet.Id.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 196deca..22985d9 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -24,7 +24,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 @Immutable
-public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
+public enum PatchSetProtoConverter implements SafeProtoConverter<Entities.PatchSet, PatchSet> {
   INSTANCE;
 
   private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -103,4 +103,14 @@
   public Parser<Entities.PatchSet> getParser() {
     return Entities.PatchSet.parser();
   }
+
+  @Override
+  public Class<Entities.PatchSet> getProtoClass() {
+    return Entities.PatchSet.class;
+  }
+
+  @Override
+  public Class<PatchSet> getEntityClass() {
+    return PatchSet.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
index 6bb0f79..320b8fc 100644
--- a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
@@ -16,12 +16,14 @@
 
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Project_NameKey;
 import com.google.protobuf.Parser;
 
 @Immutable
 public enum ProjectNameKeyProtoConverter
-    implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
+    implements SafeProtoConverter<Entities.Project_NameKey, Project.NameKey> {
   INSTANCE;
 
   @Override
@@ -38,4 +40,14 @@
   public Parser<Entities.Project_NameKey> getParser() {
     return Entities.Project_NameKey.parser();
   }
+
+  @Override
+  public Class<Project_NameKey> getProtoClass() {
+    return Project_NameKey.class;
+  }
+
+  @Override
+  public Class<NameKey> getEntityClass() {
+    return NameKey.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
new file mode 100644
index 0000000..f4a66a0
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
@@ -0,0 +1,29 @@
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.protobuf.Message;
+
+/**
+ * An extension to {@link ProtoConverter} that enforces the Entity class and the Proto class to stay
+ * in sync. The enforcement is done by {@link SafeProtoConverterTest}.
+ *
+ * <p>Requirements:
+ *
+ * <ul>
+ *   <li>Implementing classes must be enums with a single value. Please prefer descriptive enum and
+ *       instance names, such as {@code MyTypeConverter::MY_TYPE_CONVERTER}.
+ *   <li>The Java Entity class must be annotated with {@link ConvertibleToProto}.
+ * </ul>
+ *
+ * <p>All safe converters are tested using {@link SafeProtoConverterTest}. Therefore, unless your
+ * Entity class has a {@code defaults()} method, or other methods besides simple getters and
+ * setters, there is no need to explicitly test your safe converter.
+ */
+@Immutable
+public interface SafeProtoConverter<P extends Message, C> extends ProtoConverter<P, C> {
+
+  Class<P> getProtoClass();
+
+  Class<C> getEntityClass();
+}
diff --git a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 8273d84..4ad29df 100644
--- a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -31,6 +31,7 @@
   public Boolean canAdd;
   public Boolean canAddTags;
   public Boolean configVisible;
+  public Boolean requireChangeForConfigUpdate;
   public Map<String, GroupInfo> groups;
   public List<WebLinkInfo> configWebLinks;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
index 493329c..b9707e9 100644
--- a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
+
 /** Information about a patch to apply. */
 public class ApplyPatchInput {
   /**
@@ -22,4 +24,10 @@
    * <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
    */
   public String patch;
+
+  /**
+   * If {@code true}, the operation will succeed if a conflict is detected. Conflict markers will be
+   * added to the conflicting files.
+   */
+  @Nullable public Boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
index dd29635..21bf886 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.List;
 import java.util.Objects;
 
 /** Detailed information about who should be notified about an update. */
+@ConvertibleToProto
 public class NotifyInfo {
   public List<String> accounts;
 
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 906fc4c..805e769 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -40,4 +40,5 @@
   public ProjectState state;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
   public Map<String, CommentLinkInput> commentLinks;
+  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 0b1b6b0..58fd93a8 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ListTagSortOption;
@@ -59,6 +60,9 @@
   @CanIgnoreReturnValue
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
+  ChangeInfo configReview(ConfigInput in) throws RestApiException;
+
   Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException;
 
@@ -284,6 +288,25 @@
    */
   void labels(BatchLabelInput input) throws RestApiException;
 
+  /** Same as {@link #labels(BatchLabelInput)}, but creates a change with required updates. */
+  @CanIgnoreReturnValue
+  ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException;
+
+  /**
+   * Adds, updates and deletes submit requirements definitions in a batch.
+   *
+   * @param input input that describes additions, updates and deletions of submit requirements
+   */
+  void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException;
+
+  /**
+   * Creates a change with required submit requirements updates.
+   *
+   * <p>See {@link #submitRequirements(BatchSubmitRequirementInput)} for details
+   */
+  @CanIgnoreReturnValue
+  ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -345,6 +368,11 @@
     }
 
     @Override
+    public ChangeInfo configReview(ConfigInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
         throws RestApiException {
       throw new NotImplementedException();
@@ -489,5 +517,21 @@
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 109afd6..ad494cb 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.client;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.Objects;
 
+@ConvertibleToProto
 public class DiffPreferencesInfo {
 
   /** Default number of lines of context. */
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 0a3ec0a..5da211e 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.extensions.client;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.Objects;
 
 /* This class is stored in Git config file. */
+@ConvertibleToProto
 public class EditPreferencesInfo {
   public Integer tabSize;
   public Integer lineLength;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ed9793..44fc4d5 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.extensions.client;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.List;
 import java.util.Objects;
 
 /** Preferences about a single user. */
+@ConvertibleToProto
 public class GeneralPreferencesInfo {
 
   /** Default number of items to display per page. */
@@ -143,6 +145,8 @@
   public List<String> changeTable;
   public Boolean allowBrowserNotifications;
   public Boolean allowSuggestCodeWhileCommenting;
+  public Boolean allowAutocompletingComments;
+
   /**
    * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
    * sidebars should be closed.
@@ -214,6 +218,9 @@
         && Objects.equals(this.my, other.my)
         && Objects.equals(this.changeTable, other.changeTable)
         && Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications)
+        && Objects.equals(
+            this.allowSuggestCodeWhileCommenting, other.allowSuggestCodeWhileCommenting)
+        && Objects.equals(this.allowAutocompletingComments, other.allowAutocompletingComments)
         && Objects.equals(this.diffPageSidebar, other.diffPageSidebar);
   }
 
@@ -242,6 +249,8 @@
         my,
         changeTable,
         allowBrowserNotifications,
+        allowSuggestCodeWhileCommenting,
+        allowAutocompletingComments,
         diffPageSidebar);
   }
 
@@ -270,6 +279,8 @@
         .add("my", my)
         .add("changeTable", changeTable)
         .add("allowBrowserNotifications", allowBrowserNotifications)
+        .add("allowSuggestCodeWhileCommenting", allowSuggestCodeWhileCommenting)
+        .add("allowAutocompletingComments", allowAutocompletingComments)
         .add("diffPageSidebar", diffPageSidebar)
         .toString();
   }
@@ -296,8 +307,9 @@
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
     p.allowBrowserNotifications = true;
-    p.diffPageSidebar = "NONE";
     p.allowSuggestCodeWhileCommenting = true;
+    p.allowAutocompletingComments = true;
+    p.diffPageSidebar = "NONE";
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/AbstractBatchInput.java b/java/com/google/gerrit/extensions/common/AbstractBatchInput.java
new file mode 100644
index 0000000..b872b18
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AbstractBatchInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+import java.util.Map;
+
+/** Input for the REST API that describes additions, updates and deletions items in a collection. */
+public abstract class AbstractBatchInput<T> {
+  public String commitMessage;
+  public List<String> delete;
+  public List<T> create;
+  public Map<String, T> update;
+}
diff --git a/java/com/google/gerrit/extensions/common/BatchLabelInput.java b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
index eb4c581..aa91314 100644
--- a/java/com/google/gerrit/extensions/common/BatchLabelInput.java
+++ b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
@@ -14,13 +14,5 @@
 
 package com.google.gerrit.extensions.common;
 
-import java.util.List;
-import java.util.Map;
-
 /** Input for the REST API that describes additions, updates and deletions of label definitions. */
-public class BatchLabelInput {
-  public String commitMessage;
-  public List<String> delete;
-  public List<LabelDefinitionInput> create;
-  public Map<String, LabelDefinitionInput> update;
-}
+public class BatchLabelInput extends AbstractBatchInput<LabelDefinitionInput> {}
diff --git a/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
new file mode 100644
index 0000000..8a5f30d
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/**
+ * Input for the REST API that describes additions, updates and deletions of submit requirements.
+ */
+public class BatchSubmitRequirementInput extends AbstractBatchInput<SubmitRequirementInput> {}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 52127e4..1576e68 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -93,6 +93,7 @@
    * <p>Only set if this change info is returned in response to a request that creates a new change
    * or patch set and conflicts are allowed. In particular this field is only populated if the
    * change info is returned by one of the following REST endpoints: {@link
+   * com.google.gerrit.server.restapi.change.ApplyPatch},{@link
    * com.google.gerrit.server.restapi.change.CreateChange}, {@link
    * com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
    * com.google.gerrit.server.restapi.change.CherryPick}, {@link
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index d59595a..2d91515 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -72,11 +72,11 @@
    * @param unit time unit of the value
    */
   public final void record(long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     LoggingContext.getInstance()
-        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
-    logger.atFinest().log("%s took %dms", name, durationMs);
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos));
+    logger.atFinest().log("%s took %.2f ms", name, durationNanos / 1000000.0);
 
     doRecord(value, unit);
     RequestStateContext.abortIfCancelled();
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index eefd462..9dc32da 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -54,8 +54,6 @@
     }
   }
 
-  private boolean suppressLogging;
-
   protected final String name;
   protected final Field<F1> field;
 
@@ -83,28 +81,21 @@
    * @param unit time unit of the value
    */
   public final void record(F1 fieldValue, long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     Metadata.Builder metadataBuilder = Metadata.builder();
     field.metadataMapper().accept(metadataBuilder, fieldValue);
     Metadata metadata = metadataBuilder.build();
 
-    if (!suppressLogging) {
-      LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
-      logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
-    }
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos, metadata));
+    logger.atFinest().log(
+        "%s (%s = %s) took %.2f ms", name, field.name(), fieldValue, durationNanos / 1000000.0);
 
     doRecord(fieldValue, value, unit);
     RequestStateContext.abortIfCancelled();
   }
 
-  /** Suppress logging (debug log and performance log) when values are recorded. */
-  public final Timer1<F1> suppressLogging() {
-    this.suppressLogging = true;
-    return this;
-  }
-
   /**
    * Record a value in the distribution.
    *
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index 09878ad..46dd617 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -57,8 +57,6 @@
     }
   }
 
-  private boolean suppressLogging;
-
   protected final String name;
   protected final Field<F1> field1;
   protected final Field<F2> field2;
@@ -90,31 +88,23 @@
    * @param unit time unit of the value
    */
   public final void record(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     Metadata.Builder metadataBuilder = Metadata.builder();
     field1.metadataMapper().accept(metadataBuilder, fieldValue1);
     field2.metadataMapper().accept(metadataBuilder, fieldValue2);
     Metadata metadata = metadataBuilder.build();
 
-    if (!suppressLogging) {
-      LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
-      logger.atFinest().log(
-          "%s (%s = %s, %s = %s) took %dms",
-          name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
-    }
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos, metadata));
+    logger.atFinest().log(
+        "%s (%s = %s, %s = %s) took %.2f ms",
+        name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationNanos / 1000000.0);
 
     doRecord(fieldValue1, fieldValue2, value, unit);
     RequestStateContext.abortIfCancelled();
   }
 
-  /** Suppress logging (debug log and performance log) when values are recorded. */
-  public final Timer2<F1, F2> suppressLogging() {
-    this.suppressLogging = true;
-    return this;
-  }
-
   /**
    * Record a value in the distribution.
    *
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 5d5c424..922cdd6 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -60,8 +60,6 @@
     }
   }
 
-  private boolean suppressLogging;
-
   protected final String name;
   protected final Field<F1> field1;
   protected final Field<F2> field2;
@@ -98,7 +96,7 @@
    */
   public final void record(
       F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     Metadata.Builder metadataBuilder = Metadata.builder();
     field1.metadataMapper().accept(metadataBuilder, fieldValue1);
@@ -106,31 +104,23 @@
     field3.metadataMapper().accept(metadataBuilder, fieldValue3);
     Metadata metadata = metadataBuilder.build();
 
-    if (!suppressLogging) {
-      LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
-      logger.atFinest().log(
-          "%s (%s = %s, %s = %s, %s = %s) took %dms",
-          name,
-          field1.name(),
-          fieldValue1,
-          field2.name(),
-          fieldValue2,
-          field3.name(),
-          fieldValue3,
-          durationMs);
-    }
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos, metadata));
+    logger.atFinest().log(
+        "%s (%s = %s, %s = %s, %s = %s) took %.2f ms",
+        name,
+        field1.name(),
+        fieldValue1,
+        field2.name(),
+        fieldValue2,
+        field3.name(),
+        fieldValue3,
+        durationNanos / 1000000.0);
 
     doRecord(fieldValue1, fieldValue2, fieldValue3, value, unit);
     RequestStateContext.abortIfCancelled();
   }
 
-  /** Suppress logging (debug log and performance log) when values are recorded. */
-  public final Timer3<F1, F2, F3> suppressLogging() {
-    this.suppressLogging = true;
-    return this;
-  }
-
   /**
    * Record a value in the distribution.
    *
diff --git a/java/com/google/gerrit/pgm/Passwd.java b/java/com/google/gerrit/pgm/Passwd.java
index 10ed07d..dbb5318 100644
--- a/java/com/google/gerrit/pgm/Passwd.java
+++ b/java/com/google/gerrit/pgm/Passwd.java
@@ -36,33 +36,40 @@
 
 public class Passwd extends SiteProgram {
   private String section;
+  private String subsection;
   private String key;
 
   @Argument(
-      metaVar = "SECTION.KEY",
+      metaVar = "SECTION.[SUBSECTION.]KEY",
       index = 0,
       required = true,
-      usage = "Section and key separated by a dot of the password to set")
-  private String sectionAndKey;
+      usage =
+          "Section, subsection and key separated by a dot of the password to set. Subsection is optional")
+  private String sectionSubsectionAndKey;
 
   @Argument(metaVar = "PASSWORD", index = 1, required = false, usage = "Password to set")
   private String password;
 
   private void init() {
-    List<String> varParts = Splitter.on('.').splitToList(sectionAndKey);
-    if (varParts.size() != 2) {
+    List<String> varParts = Splitter.on('.').splitToList(sectionSubsectionAndKey);
+    if (varParts.size() != 2 && varParts.size() != 3) {
       throw new IllegalArgumentException(
-          "Invalid name '" + sectionAndKey + "': expected section.key format");
+          "Invalid name '"
+              + sectionSubsectionAndKey
+              + "': expected section.[subsection.]key format");
     }
     section = varParts.get(0);
-    key = varParts.get(1);
+    if (varParts.size() == 3) {
+      subsection = varParts.get(1);
+    }
+    key = varParts.get(varParts.size() - 1);
   }
 
   @Override
   public int run() throws Exception {
     init();
     SetPasswd setPasswd = getSysInjector().getInstance(SetPasswd.class);
-    setPasswd.run(section, key, password);
+    setPasswd.run(section, subsection, key, password);
     return 0;
   }
 
diff --git a/java/com/google/gerrit/pgm/SetPasswd.java b/java/com/google/gerrit/pgm/SetPasswd.java
index c3f6a7b..edf3870 100644
--- a/java/com/google/gerrit/pgm/SetPasswd.java
+++ b/java/com/google/gerrit/pgm/SetPasswd.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.inject.Inject;
@@ -29,8 +30,9 @@
     this.sections = sections;
   }
 
-  public void run(String section, String key, String password) throws Exception {
-    Section passwordSection = sections.get(section, null);
+  public void run(String section, @Nullable String subsection, String key, String password)
+      throws Exception {
+    Section passwordSection = sections.get(section, subsection);
 
     if (ui.isBatch()) {
       passwordSection.setSecure(key, password);
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index f074522..cac515c 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -10,7 +10,7 @@
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
-// limitations under the Licens
+// limitations under the License
 
 package com.google.gerrit.server.account;
 
@@ -410,7 +410,8 @@
      * @return the builder
      */
     @CanIgnoreReturnValue
-    public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+    public Builder updateProjectWatches(
+        Map<ProjectWatchKey, ? extends Set<NotifyType>> projectWatches) {
       updatedProjectWatchesBuilder().putAll(projectWatches);
       return this;
     }
@@ -615,7 +616,8 @@
       }
 
       @Override
-      public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+      public Builder updateProjectWatches(
+          Map<ProjectWatchKey, ? extends Set<NotifyType>> projectWatches) {
         delegate.updateProjectWatches(projectWatches);
         return this;
       }
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index a037046..93b4cd3 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -98,7 +98,9 @@
    * @return limit according to {@link GlobalCapability#QUERY_LIMIT}.
    */
   public int getQueryLimit() {
-    return getRange(GlobalCapability.QUERY_LIMIT).getMax();
+    return user.isInternalUser()
+        ? Integer.MAX_VALUE
+        : getRange(GlobalCapability.QUERY_LIMIT).getMax();
   }
 
   /** Returns true if the user has a permission rule specifying the range. */
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index c260401..42687987 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -21,6 +21,9 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+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.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -90,7 +93,9 @@
   }
 
   public void fill() throws PermissionBackendException {
-    directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
+    try (TraceTimer timer = TraceContext.newTimer("Fill accounts", Metadata.empty())) {
+      directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
+    }
   }
 
   public void fill(Collection<? extends AccountInfo> infos) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 61012d7..51948f9 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
@@ -60,7 +61,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
@@ -269,7 +269,7 @@
   private void update(AuthRequest who, ExternalId extId)
       throws IOException, ConfigInvalidException, AccountException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
+    List<ConfigureStatelessDelta> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -311,7 +311,7 @@
               .update(
                   "Update Account on Login",
                   user.getAccountId(),
-                  AccountsUpdate.joinConsumers(accountUpdates));
+                  AccountsUpdate.joinDeltaConfigures(accountUpdates));
       if (!updatedAccount.isPresent()) {
         throw new StorageException("Account " + user.getAccountId() + " has been deleted");
       }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5951a73..5ec97ff 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -20,20 +20,23 @@
 import static java.lang.annotation.ElementType.PARAMETER;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.inject.BindingAnnotation;
 import java.io.IOException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 import java.util.List;
 import java.util.Optional;
-import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 
@@ -49,6 +52,7 @@
  * <p>See the implementing classes for more information.
  */
 public abstract class AccountsUpdate {
+  /** Loader for {@link AccountsUpdate}s. */
   public interface AccountsUpdateLoader {
     /**
      * Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
@@ -87,24 +91,74 @@
   public static class UpdateArguments {
     public final String message;
     public final Account.Id accountId;
-    public final AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState;
+    public final ConfigureDeltaFromStateAndContext configureDelta;
 
     public UpdateArguments(
-        String message,
-        Account.Id accountId,
-        AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState) {
+        String message, Account.Id accountId, ConfigureStatelessDelta configureDelta) {
+      this(message, accountId, withContext(configureDelta));
+    }
+
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromState configureDelta) {
+      this(message, accountId, withContext(configureDelta));
+    }
+
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromStateAndContext configureDelta) {
       this.message = message;
       this.accountId = accountId;
-      this.configureDeltaFromState = configureDeltaFromState;
+      this.configureDelta = configureDelta;
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("message", message)
+          .add("accountId", accountId)
+          .toString();
     }
   }
 
   /**
-   * Account updates are commonly performed by evaluating the current account state and creating a
-   * delta to be applied to it in a later step. This is done by implementing this interface.
+   * Storage readers/writers which are accessible through {@link ConfigureDeltaFromStateAndContext}.
    *
-   * <p>If the current account state is not needed, use a {@link Consumer} of {@link
-   * com.google.gerrit.server.account.AccountDelta.Builder} instead.
+   * <p>If you need to perform extra reads/writes during the update, prefer using these, to avoid
+   * mishmash between different storage systems where multiple ones are supported. If you need an
+   * accessor which is not yet here, please do add it.
+   */
+  public interface InUpdateStorageAccessors {
+    ExternalIds externalIdsReader();
+  }
+
+  /**
+   * The most basic interface for updating the account delta, providing no state nor context.
+   *
+   * <p>Account updates that do not need to know the current account state, nor read/write any extra
+   * data during the update, should use this interface.
+   *
+   * <p>If the above capabilities are needed, use {@link ConfigureDeltaFromState} or {@link
+   * ConfigureDeltaFromStateAndContext} instead.
+   */
+  @FunctionalInterface
+  public interface ConfigureStatelessDelta {
+    /**
+     * Configures an {@link com.google.gerrit.server.account.AccountDelta.Builder} with changes to
+     * the account.
+     *
+     * @param delta the changes to be applied
+     */
+    void configure(AccountDelta.Builder delta);
+  }
+
+  /**
+   * Interface for updating the account delta, providing the current state.
+   *
+   * <p>Account updates are commonly performed by evaluating the current account state and creating
+   * a delta to be applied to it in a later step. This is done by implementing this interface.
+   *
+   * <p>If the current account state is not needed, use {@link ConfigureStatelessDelta} instead.
+   * Alternatively, if you need to perform extra storage reads/writes during the update, use {@link
+   * ConfigureDeltaFromStateAndContext}.
    */
   @FunctionalInterface
   public interface ConfigureDeltaFromState {
@@ -118,42 +172,68 @@
     void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
   }
 
-  /** Returns an instance that runs all specified consumers. */
-  public static ConfigureDeltaFromState joinConsumers(
-      List<Consumer<AccountDelta.Builder>> consumers) {
-    return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
+  /**
+   * Interface for updating the account delta, providing the current state and storage accessors.
+   *
+   * <p>Account updates which need to perform extra storage reads/writes during the update, should
+   * use this interface.
+   *
+   * <p>If storage accessors are not needed, use {@link ConfigureStatelessDelta} or {@link
+   * ConfigureDeltaFromState} instead.
+   */
+  @FunctionalInterface
+  public interface ConfigureDeltaFromStateAndContext {
+    /**
+     * Receives {@link InUpdateStorageAccessors} for reading/modifying data on the respective
+     * storage system, as well as the current {@link AccountState} (which is immutable). Configures
+     * an {@link com.google.gerrit.server.account.AccountDelta.Builder} with changes to the account.
+     *
+     * @param inUpdateStorageAccessors storage accessor which have the context of the update (e.g.,
+     *     use the same storage system as the calling updater)
+     * @param accountState the state of the account that is being updated
+     * @param delta the changes to be applied
+     * @see InUpdateStorageAccessors
+     */
+    void configure(
+        InUpdateStorageAccessors inUpdateStorageAccessors,
+        AccountState accountState,
+        AccountDelta.Builder delta)
+        throws IOException;
   }
 
-  static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
-    return (a, u) -> consumer.accept(u);
+  /** Returns an instance that runs all specified consumers. */
+  public static ConfigureStatelessDelta joinDeltaConfigures(
+      List<ConfigureStatelessDelta> deltaConfigures) {
+    return (update) -> deltaConfigures.forEach(c -> c.configure(update));
   }
 
   protected final PersonIdent committerIdent;
   protected final PersonIdent authorIdent;
 
   protected final Optional<IdentifiedUser> currentUser;
+  private final InUpdateStorageAccessors inUpdateStorageAccessors;
 
-  protected AccountsUpdate(PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+  @SuppressWarnings("Convert2Lambda")
+  protected AccountsUpdate(
+      PersonIdent serverIdent, Optional<IdentifiedUser> user, ExternalIds externalIdsReader) {
     this.currentUser = user;
     this.committerIdent = serverIdent;
     this.authorIdent = createPersonIdent(serverIdent, user);
-  }
-
-  /**
-   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
-   * instead, i.e. the update does not depend on the current account state (which, for insertion,
-   * would only contain the account ID).
-   */
-  @CanIgnoreReturnValue
-  public AccountState insert(
-      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
-      throws IOException, ConfigInvalidException {
-    return insert(message, accountId, AccountsUpdate.fromConsumer(init));
+    this.inUpdateStorageAccessors =
+        new InUpdateStorageAccessors() {
+          @Override
+          public ExternalIds externalIdsReader() {
+            return externalIdsReader;
+          }
+        };
   }
 
   /**
    * Inserts a new account.
    *
+   * <p>If the current account state is not needed, use {@link #insert(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
+   *
    * @param message commit message for the account creation, must not be {@code null or empty}
    * @param accountId ID of the new account
    * @param init to populate the new account
@@ -162,19 +242,36 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
+  @CanIgnoreReturnValue
   public abstract AccountState insert(
-      String message, Account.Id accountId, ConfigureDeltaFromState init)
+      String message, Account.Id accountId, ConfigureDeltaFromStateAndContext init)
       throws IOException, ConfigInvalidException;
 
   /**
-   * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
-   * instead, i.e. the update does not depend on the current account state.
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+   * ConfigureDeltaFromState} instead. I.e. the update does not require any extra storage
+   * reads/writes, except for the current {@link AccountState}.
+   *
+   * <p>If the current account state is not needed as well, use {@link #insert(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
    */
   @CanIgnoreReturnValue
-  public Optional<AccountState> update(
-      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+  public final AccountState insert(
+      String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
-    return update(message, accountId, AccountsUpdate.fromConsumer(update));
+    return insert(message, accountId, withContext(init));
+  }
+
+  /**
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+   * ConfigureStatelessDelta} instead. I.e. the update does not depend on the current account state,
+   * nor requires any extra storage reads/writes.
+   */
+  @CanIgnoreReturnValue
+  public final AccountState insert(
+      String message, Account.Id accountId, ConfigureStatelessDelta init)
+      throws IOException, ConfigInvalidException {
+    return insert(message, accountId, withContext(init));
   }
 
   /**
@@ -182,10 +279,12 @@
    *
    * <p>Changing the registration date of an account is not supported.
    *
+   * <p>If the current account state is not needed, use {@link #update(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
+   *
    * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param configureDeltaFromState deltaBuilder to update the account, only invoked if the account
-   *     exists
+   * @param configureDelta deltaBuilder to update the account, only invoked if the account exists
    * @return the updated account, {@link Optional#empty} if the account doesn't exist
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
@@ -193,15 +292,41 @@
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   @CanIgnoreReturnValue
-  public Optional<AccountState> update(
-      String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
+  public final Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromStateAndContext configureDelta)
       throws IOException, ConfigInvalidException {
-    return updateBatch(
-            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+    return updateBatch(ImmutableList.of(new UpdateArguments(message, accountId, configureDelta)))
         .get(0);
   }
 
   /**
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+   * ConfigureDeltaFromState} instead. I.e. the update does not require any extra storage
+   * reads/writes, except for the current {@link AccountState}.
+   *
+   * <p>If the current account state is not needed as well, use {@link #update(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
+   */
+  @CanIgnoreReturnValue
+  public final Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromState configureDelta)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, withContext(configureDelta));
+  }
+
+  /**
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromStateAndContext)} , but using {@link
+   * ConfigureStatelessDelta} instead. I.e. the update does not depend on the current account state,
+   * nor requires any extra storage reads/writes.
+   */
+  @CanIgnoreReturnValue
+  public final Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureStatelessDelta configureDelta)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, withContext(configureDelta));
+  }
+
+  /**
    * Updates multiple different accounts atomically. This will only store a single new value (aka
    * set of all external IDs of the host) in the external ID cache, which is important for storage
    * economy. All {@code updates} must be for different accounts.
@@ -212,7 +337,7 @@
    * together have this property) will always prevent the entire batch from being executed.
    */
   @CanIgnoreReturnValue
-  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+  public final ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
       throws IOException, ConfigInvalidException {
     checkArgument(
         updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
@@ -231,9 +356,33 @@
   public abstract void delete(String message, Account.Id accountId)
       throws IOException, ConfigInvalidException;
 
-  protected abstract ImmutableList<Optional<AccountState>> executeUpdates(
+  @VisibleForTesting // productionVisibility: protected
+  public abstract ImmutableList<Optional<AccountState>> executeUpdates(
       List<UpdateArguments> updates) throws ConfigInvalidException, IOException;
 
+  /**
+   * Intended for internal usage only. This is public because some implementations are calling this
+   * method for other instances.
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public final void configureDelta(
+      ConfigureDeltaFromStateAndContext configureDelta,
+      AccountState accountState,
+      AccountDelta.Builder delta)
+      throws IOException {
+    configureDelta.configure(inUpdateStorageAccessors, accountState, delta);
+  }
+
+  private static ConfigureDeltaFromStateAndContext withContext(
+      ConfigureDeltaFromState configureDelta) {
+    return (unusedAccessors, accountState, delta) -> configureDelta.configure(accountState, delta);
+  }
+
+  private static ConfigureDeltaFromStateAndContext withContext(
+      ConfigureStatelessDelta configureDelta) {
+    return (unusedAccessors, unusedAccountState, delta) -> configureDelta.configure(delta);
+  }
+
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
     return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 86132d3..341b493 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig;
@@ -79,6 +80,7 @@
  */
 public class ProjectWatches {
   @AutoValue
+  @ConvertibleToProto
   public abstract static class ProjectWatchKey {
 
     public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index db030f9..a05baf5 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.IdentifiedUser;
+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.inject.AbstractModule;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
@@ -63,41 +66,43 @@
 
   @Override
   public boolean isServiceUser(Account.Id user) {
-    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
-    if (!maybeGroup.isPresent()) {
+    try (TraceTimer timer = TraceContext.newTimer("isServiceUser", Metadata.empty())) {
+      Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
+      if (!maybeGroup.isPresent()) {
+        return false;
+      }
+      List<AccountGroup.UUID> toTraverse = new ArrayList<>();
+      toTraverse.add(maybeGroup.get().getGroupUUID());
+      Set<AccountGroup.UUID> seen = new HashSet<>();
+      while (!toTraverse.isEmpty()) {
+        InternalGroup currentGroup =
+            groupCache
+                .get(toTraverse.remove(0))
+                .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
+        if (seen.contains(currentGroup.getGroupUUID())) {
+          logger.atFine().log(
+              "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
+          continue;
+        }
+        seen.add(currentGroup.getGroupUUID());
+        if (currentGroup.getMembers().contains(user)) {
+          // The user is a member of the 'Service Users' group or a subgroup.
+          return true;
+        }
+        boolean hasExternalSubgroup =
+            currentGroup.getSubgroups().stream().anyMatch(g -> !internalGroupBackend.handles(g));
+        if (hasExternalSubgroup) {
+          // 'Service Users or a subgroup of Service User' contains an external subgroup, so we have
+          // to default to the more expensive evaluation of getting all of the user's group
+          // memberships.
+          return identifiedUserFactory
+              .create(user)
+              .getEffectiveGroups()
+              .contains(maybeGroup.get().getGroupUUID());
+        }
+        toTraverse.addAll(currentGroup.getSubgroups());
+      }
       return false;
     }
-    List<AccountGroup.UUID> toTraverse = new ArrayList<>();
-    toTraverse.add(maybeGroup.get().getGroupUUID());
-    Set<AccountGroup.UUID> seen = new HashSet<>();
-    while (!toTraverse.isEmpty()) {
-      InternalGroup currentGroup =
-          groupCache
-              .get(toTraverse.remove(0))
-              .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
-      if (seen.contains(currentGroup.getGroupUUID())) {
-        logger.atFine().log(
-            "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
-        continue;
-      }
-      seen.add(currentGroup.getGroupUUID());
-      if (currentGroup.getMembers().contains(user)) {
-        // The user is a member of the 'Service Users' group or a subgroup.
-        return true;
-      }
-      boolean hasExternalSubgroup =
-          currentGroup.getSubgroups().stream().anyMatch(g -> !internalGroupBackend.handles(g));
-      if (hasExternalSubgroup) {
-        // 'Service Users or a subgroup of Service User' contains an external subgroup, so we have
-        // to default to the more expensive evaluation of getting all of the user's group
-        // memberships.
-        return identifiedUserFactory
-            .create(user)
-            .getEffectiveGroups()
-            .contains(maybeGroup.get().getGroupUUID());
-      }
-      toTraverse.addAll(currentGroup.getSubgroups());
-    }
-    return false;
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index ad3681d..551cd78 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -61,7 +61,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.function.Consumer;
 import javax.inject.Inject;
 import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -83,8 +82,8 @@
  * com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState}. The account updater
  * reads the current {@link AccountState} and prepares updates to the account by calling setters on
  * the provided {@link com.google.gerrit.server.account.AccountDelta.Builder}. If the current
- * account state is of no interest the caller may also provide a {@link Consumer} for {@link
- * com.google.gerrit.server.account.AccountDelta.Builder} instead of the account updater.
+ * account state is of no interest the caller may also provide a {@link ConfigureStatelessDelta}
+ * instead of the account updater.
  *
  * <p>The provided commit message is used for the update of the user branch. Using a precise and
  * unique commit message allows to identify the code from which an update was made when looking at a
@@ -272,7 +271,7 @@
       PersonIdent committerIdent,
       Runnable afterReadRevision,
       Runnable beforeCommit) {
-    super(committerIdent, currentUser);
+    super(committerIdent, currentUser, externalIds);
     this.repoManager = requireNonNull(repoManager, "repoManager");
     this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
     this.allUsersName = requireNonNull(allUsersName, "allUsersName");
@@ -286,7 +285,8 @@
   }
 
   @Override
-  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
+  public AccountState insert(
+      String message, Account.Id accountId, ConfigureDeltaFromStateAndContext init)
       throws IOException, ConfigInvalidException {
     return execute(
             ImmutableList.of(
@@ -295,7 +295,7 @@
                   Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-                  init.configure(accountState, deltaBuilder);
+                  configureDelta(init, accountState, deltaBuilder);
 
                   AccountDelta accountDelta = deltaBuilder.build();
                   accountConfig.setAccountDelta(accountDelta);
@@ -315,8 +315,7 @@
   public void delete(String message, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
-    Consumer<AccountDelta.Builder> delta =
-        deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+    ConfigureStatelessDelta delta = deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
     update(message, accountId, delta);
   }
 
@@ -332,7 +331,7 @@
       }
 
       AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
+      configureDelta(updateArguments.configureDelta, accountState.get(), deltaBuilder);
 
       AccountDelta delta = deltaBuilder.build();
       updateExternalIdNotes(
@@ -380,7 +379,8 @@
   }
 
   @Override
-  protected ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
+  @VisibleForTesting
+  public ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
       throws ConfigInvalidException, IOException {
     return execute(updates.stream().map(this::createExecutableUpdate).collect(toImmutableList()));
   }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5c24ddc..66914b7 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -86,8 +87,12 @@
 import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.gerrit.server.restapi.project.PostLabelsReview;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirements;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirementsReview;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
+import com.google.gerrit.server.restapi.project.PutConfigReview;
 import com.google.gerrit.server.restapi.project.PutDescription;
 import com.google.gerrit.server.restapi.project.SetAccess;
 import com.google.gerrit.server.restapi.project.SetHead;
@@ -127,6 +132,7 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
+  private final PutConfigReview putConfigReview;
   private final CommitsIncludedInRefs commitsIncludedInRefs;
   private final Provider<ListBranches> listBranches;
   private final Provider<ListTags> listTags;
@@ -147,6 +153,10 @@
   private final Provider<ListLabels> listLabels;
   private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
+  private final PostLabelsReview postLabelsReview;
+
+  private final PostSubmitRequirements postSubmitRequirements;
+  private final PostSubmitRequirementsReview postSubmitRequirementsReview;
   private final LabelApiImpl.Factory labelApi;
   private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
@@ -168,6 +178,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -188,8 +199,11 @@
       Provider<ListLabels> listLabels,
       Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
+      PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -208,6 +222,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        putConfigReview,
         commitsIncludedInRefs,
         listBranches,
         listTags,
@@ -229,8 +244,11 @@
         listLabels,
         listSubmitRequirements,
         postLabels,
+        postLabelsReview,
         labelApi,
         submitRequirementApi,
+        postSubmitRequirements,
+        postSubmitRequirementsReview,
         null);
   }
 
@@ -252,6 +270,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -272,8 +291,11 @@
       Provider<ListLabels> listLabels,
       Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
+      PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -292,6 +314,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        putConfigReview,
         commitsIncludedInRefs,
         listBranches,
         listTags,
@@ -313,8 +336,11 @@
         listLabels,
         listSubmitRequirements,
         postLabels,
+        postLabelsReview,
         labelApi,
         submitRequirementApi,
+        postSubmitRequirements,
+        postSubmitRequirementsReview,
         name);
   }
 
@@ -335,6 +361,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -356,8 +383,11 @@
       Provider<ListLabels> listLabels,
       Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
+      PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -375,6 +405,7 @@
     this.setAccess = setAccess;
     this.getConfig = getConfig;
     this.putConfig = putConfig;
+    this.putConfigReview = putConfigReview;
     this.commitsIncludedInRefs = commitsIncludedInRefs;
     this.listBranches = listBranches;
     this.listTags = listTags;
@@ -397,8 +428,11 @@
     this.listLabels = listLabels;
     this.listSubmitRequirements = listSubmitRequirements;
     this.postLabels = postLabels;
+    this.postLabelsReview = postLabelsReview;
     this.labelApi = labelApi;
     this.submitRequirementApi = submitRequirementApi;
+    this.postSubmitRequirements = postSubmitRequirements;
+    this.postSubmitRequirementsReview = postSubmitRequirementsReview;
   }
 
   @Override
@@ -519,6 +553,15 @@
   }
 
   @Override
+  public ChangeInfo configReview(ConfigInput p) throws RestApiException {
+    try {
+      return putConfigReview.apply(checkExists(), p).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put config change", e);
+    }
+  }
+
+  @Override
   public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException {
     try {
@@ -814,4 +857,33 @@
       throw asRestApiException("Cannot update labels", e);
     }
   }
+
+  @Override
+  public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
+    try {
+      return postLabelsReview.apply(checkExists(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change for labels update", e);
+    }
+  }
+
+  @Override
+  public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+    try {
+      @SuppressWarnings("unused")
+      var unused = postSubmitRequirements.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirements", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+      throws RestApiException {
+    try {
+      return postSubmitRequirementsReview.apply(checkExists(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change for submit requirements update", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 07280ba..1a2c63d 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
@@ -40,7 +39,6 @@
 public class AbandonUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
   private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final BatchAbandon batchAbandon;
@@ -48,27 +46,27 @@
 
   @Inject
   AbandonUtil(
-      ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
       Provider<ChangeQueryBuilder> queryBuilderProvider,
       BatchAbandon batchAbandon) {
-    this.cfg = cfg;
     this.queryProvider = queryProvider;
     this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
 
-  public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
-    if (cfg.getAbandonAfter() <= 0) {
+  public void abandonInactiveOpenChanges(
+      BatchUpdate.Factory updateFactory,
+      long abandonAfterMillis,
+      boolean abandonIfMergeable,
+      String message) {
+    if (abandonAfterMillis <= 0) {
       return;
     }
-
     try {
-      String query =
-          "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
-      if (!cfg.getAbandonIfMergeable()) {
+      String query = "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(abandonAfterMillis) + "m";
+      if (!abandonIfMergeable) {
         query += " -is:mergeable";
       }
 
@@ -86,7 +84,6 @@
 
       int count = 0;
       ImmutableListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
-      String message = cfg.getAbandonMessage();
       for (Project.NameKey project : abandons.keySet()) {
         List<ChangeData> changes = getValidChanges(abandons.get(project), query);
         try {
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 2f2cff9..728830c 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 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.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -25,6 +26,8 @@
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 /** Runnable to enable scheduling change cleanups to run periodically */
 public class ChangeCleanupRunner implements Runnable {
@@ -34,18 +37,25 @@
     @Override
     protected void configure() {
       listener().to(Lifecycle.class);
+      factory(Factory.class);
     }
   }
 
+  public interface Factory {
+    ChangeCleanupRunner create();
+
+    ChangeCleanupRunner create(long abandonAfterMillis, boolean abandonIfMergeable, String message);
+  }
+
   static class Lifecycle implements LifecycleListener {
     private final WorkQueue queue;
     private final ChangeCleanupRunner runner;
     private final ChangeCleanupConfig cfg;
 
     @Inject
-    Lifecycle(WorkQueue queue, ChangeCleanupRunner runner, ChangeCleanupConfig cfg) {
+    Lifecycle(WorkQueue queue, ChangeCleanupRunner.Factory runner, ChangeCleanupConfig cfg) {
       this.queue = queue;
-      this.runner = runner;
+      this.runner = runner.create();
       this.cfg = cfg;
     }
 
@@ -63,13 +73,38 @@
   private final OneOffRequestContext oneOffRequestContext;
   private final AbandonUtil abandonUtil;
   private final RetryHelper retryHelper;
+  private final long abandonAfterMillis;
+  private final boolean abandonIfMergeable;
+  @Nullable private final String message;
 
-  @Inject
+  @AssistedInject
   ChangeCleanupRunner(
-      OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper) {
+      OneOffRequestContext oneOffRequestContext,
+      AbandonUtil abandonUtil,
+      RetryHelper retryHelper,
+      @Assisted long abandonAfterMillis,
+      @Assisted boolean abandonIfMergeable,
+      @Assisted @Nullable String message) {
     this.oneOffRequestContext = oneOffRequestContext;
     this.abandonUtil = abandonUtil;
     this.retryHelper = retryHelper;
+    this.abandonAfterMillis = abandonAfterMillis;
+    this.abandonIfMergeable = abandonIfMergeable;
+    this.message = message;
+  }
+
+  @AssistedInject
+  ChangeCleanupRunner(
+      OneOffRequestContext oneOffRequestContext,
+      AbandonUtil abandonUtil,
+      RetryHelper retryHelper,
+      ChangeCleanupConfig cfg) {
+    this.oneOffRequestContext = oneOffRequestContext;
+    this.abandonUtil = abandonUtil;
+    this.retryHelper = retryHelper;
+    this.abandonAfterMillis = cfg.getAbandonAfter();
+    this.abandonIfMergeable = cfg.getAbandonIfMergeable();
+    this.message = cfg.getAbandonMessage();
   }
 
   @Override
@@ -85,7 +120,8 @@
               .changeUpdate(
                   "abandonInactiveOpenChanges",
                   updateFactory -> {
-                    abandonUtil.abandonInactiveOpenChanges(updateFactory);
+                    abandonUtil.abandonInactiveOpenChanges(
+                        updateFactory, abandonAfterMillis, abandonIfMergeable, message);
                     return null;
                   })
               .call();
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index d4b837f..46f3e0e 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -112,8 +112,10 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -942,7 +944,12 @@
       // of "fixed" from "removable", because not all of their approvals can be
       // removed.
       Collection<LabelInfo> labels = out.labels.values();
-      Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
+      Set<Account.Id> fixed = new HashSet<>();
+
+      // the submitter cannot be removed since the submission is recorded by a SUBM approval which
+      // must not be removed by removing the submitter
+      cd.getSubmitApproval().ifPresent(submitApproval -> fixed.add(submitApproval.accountId()));
+
       Set<Account.Id> removable = new HashSet<>();
 
       // Add all reviewers, which will later be removed if they are in the "fixed" set.
@@ -953,23 +960,33 @@
               .collect(Collectors.toSet()));
 
       // Check if the user has the permission to remove a reviewer. This means we can bypass the
-      // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
-      // permission checks.
+      // permission checks for a specific reviewer in the loop saving potentially many permission
+      // checks.
+      PermissionBackend.WithUser withUser = permissionBackend.user(userProvider.get());
       boolean canRemoveAnyReviewer =
-          permissionBackend
-              .user(userProvider.get())
-              .change(cd)
-              .test(ChangePermission.REMOVE_REVIEWER);
+          withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER)
+              || withUser
+                  .project(cd.project())
+                  .ref(cd.change().getDest().branch())
+                  .test(RefPermission.WRITE_CONFIG)
+              || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+
       for (LabelInfo label : labels) {
         if (label.all == null) {
           continue;
         }
         for (ApprovalInfo ai : label.all) {
           Account.Id id = Account.id(ai._accountId);
+          if (fixed.contains(id)) {
+            // we already found that this reviewer cannot be removed, no need to check again
+            continue;
+          }
 
-          if (!canRemoveAnyReviewer
-              && !removeReviewerControl.testRemoveReviewer(
-                  cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
+          int value = MoreObjects.firstNonNull(ai.value, 0);
+          if ((cd.change().isMerged() && value != 0)
+              || (!canRemoveAnyReviewer
+                  && !RemoveReviewerControl.canRemoveReviewerWithoutPermissionCheck(
+                      cd.change(), userProvider.get(), id, value))) {
             fixed.add(id);
           }
         }
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 8300541..99a0b50 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.DISABLE_CHANGE_ETAGS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -72,6 +74,7 @@
 
   private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
 
+  private final ExperimentFeatures experimentFeatures;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalUtil;
   private final PatchSetUtil patchSetUtil;
@@ -84,6 +87,7 @@
 
   @AssistedInject
   ChangeResource(
+      ExperimentFeatures experimentFeatures,
       AccountCache accountCache,
       ApprovalsUtil approvalUtil,
       PatchSetUtil patchSetUtil,
@@ -94,6 +98,7 @@
       ChangeData.Factory changeDataFactory,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
+    this.experimentFeatures = experimentFeatures;
     this.accountCache = accountCache;
     this.approvalUtil = approvalUtil;
     this.patchSetUtil = patchSetUtil;
@@ -107,6 +112,7 @@
 
   @AssistedInject
   ChangeResource(
+      ExperimentFeatures experimentFeatures,
       AccountCache accountCache,
       ApprovalsUtil approvalUtil,
       PatchSetUtil patchSetUtil,
@@ -116,6 +122,7 @@
       PluginSetContext<ChangeETagComputation> changeETagComputation,
       @Assisted ChangeData changeData,
       @Assisted CurrentUser user) {
+    this.experimentFeatures = experimentFeatures;
     this.accountCache = accountCache;
     this.approvalUtil = approvalUtil;
     this.patchSetUtil = patchSetUtil;
@@ -231,7 +238,12 @@
   }
 
   @Override
+  @Nullable
   public String getETag() {
+    if (experimentFeatures.isFeatureEnabled(DISABLE_CHANGE_ETAGS)) {
+      return null;
+    }
+
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Compute change ETag",
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 4f001fb..b6eadb6 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -117,7 +118,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
+      throws AuthException, ResourceNotFoundException, PermissionBackendException,
+          ResourceConflictException, IOException {
     Account.Id reviewerId = reviewer.id();
     // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
     removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 93fcbc6..4e487e1 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -420,6 +420,15 @@
       // Gerrit change.
       return ObjectId.fromString(inputBase);
     }
+
+    // Support "refs/heads/..."
+    Ref ref = git.getRefDatabase().exactRef(inputBase);
+    if (ref != null
+        && isBaseRevisionInDestBranch(
+            rw, ObjectId.toString(ref.getObjectId()), git, change.getDest())) {
+      return ref.getObjectId();
+    }
+
     throw new ResourceConflictException(
         "base revision is missing from the destination branch: " + inputBase);
   }
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index 93df926..ecb0868 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -17,6 +17,9 @@
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.mergeWithDefaults;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter.DIFF_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter.EDIT_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter.GENERAL_PREFERENCES_INFO_CONVERTER;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
@@ -350,8 +353,7 @@
 
     @Override
     public GeneralPreferencesInfo fromUserPreferences(UserPreferences p) {
-      return UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
-          p.getGeneralPreferencesInfo());
+      return GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(p.getGeneralPreferencesInfo());
     }
 
     @Override
@@ -381,8 +383,7 @@
 
     @Override
     public EditPreferencesInfo fromUserPreferences(UserPreferences p) {
-      return UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
-          p.getEditPreferencesInfo());
+      return EDIT_PREFERENCES_INFO_CONVERTER.fromProto(p.getEditPreferencesInfo());
     }
 
     @Override
@@ -412,8 +413,7 @@
 
     @Override
     public DiffPreferencesInfo fromUserPreferences(UserPreferences p) {
-      return UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
-          p.getDiffPreferencesInfo());
+      return DIFF_PREFERENCES_INFO_CONVERTER.fromProto(p.getDiffPreferencesInfo());
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 7eae7d0..4b2f6d2 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -17,12 +17,14 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.SafeProtoConverter;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.protobuf.Message;
+import com.google.protobuf.Parser;
 import com.google.protobuf.ProtocolMessageEnum;
 import java.util.function.Function;
 
@@ -33,8 +35,13 @@
  * equivalents in Spanner.
  */
 public final class UserPreferencesConverter {
-  public static final class GeneralPreferencesInfoConverter {
-    public static UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
+  public enum GeneralPreferencesInfoConverter
+      implements
+          SafeProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
+    GENERAL_PREFERENCES_INFO_CONVERTER;
+
+    @Override
+    public UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
       UserPreferences.GeneralPreferencesInfo.Builder builder =
           UserPreferences.GeneralPreferencesInfo.newBuilder();
       builder = setIfNotNull(builder, builder::setChangesPerPage, info.changesPerPage);
@@ -112,11 +119,20 @@
       builder =
           setIfNotNull(
               builder, builder::setAllowBrowserNotifications, info.allowBrowserNotifications);
+      builder =
+          setIfNotNull(
+              builder,
+              builder::setAllowSuggestCodeWhileCommenting,
+              info.allowSuggestCodeWhileCommenting);
+      builder =
+          setIfNotNull(
+              builder, builder::setAllowAutocompletingComments, info.allowAutocompletingComments);
       builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar);
       return builder.build();
     }
 
-    public static GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
+    @Override
+    public GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
       GeneralPreferencesInfo res = new GeneralPreferencesInfo();
       res.changesPerPage = proto.hasChangesPerPage() ? proto.getChangesPerPage() : null;
       res.downloadScheme = proto.hasDownloadScheme() ? proto.getDownloadScheme() : null;
@@ -173,10 +189,21 @@
       res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null;
       res.allowBrowserNotifications =
           proto.hasAllowBrowserNotifications() ? proto.getAllowBrowserNotifications() : null;
+      res.allowSuggestCodeWhileCommenting =
+          proto.hasAllowSuggestCodeWhileCommenting()
+              ? proto.getAllowSuggestCodeWhileCommenting()
+              : null;
+      res.allowAutocompletingComments =
+          proto.hasAllowAutocompletingComments() ? proto.getAllowAutocompletingComments() : null;
       res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null;
       return res;
     }
 
+    @Override
+    public Parser<UserPreferences.GeneralPreferencesInfo> getParser() {
+      return UserPreferences.GeneralPreferencesInfo.parser();
+    }
+
     private static UserPreferences.GeneralPreferencesInfo.MenuItem menuItemToProto(
         MenuItem javaItem) {
       UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
@@ -201,11 +228,23 @@
           proto.hasId() ? proto.getId().trim() : null);
     }
 
-    private GeneralPreferencesInfoConverter() {}
+    @Override
+    public Class<UserPreferences.GeneralPreferencesInfo> getProtoClass() {
+      return UserPreferences.GeneralPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<GeneralPreferencesInfo> getEntityClass() {
+      return GeneralPreferencesInfo.class;
+    }
   }
 
-  public static final class DiffPreferencesInfoConverter {
-    public static UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
+  public enum DiffPreferencesInfoConverter
+      implements SafeProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
+    DIFF_PREFERENCES_INFO_CONVERTER;
+
+    @Override
+    public UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
       UserPreferences.DiffPreferencesInfo.Builder builder =
           UserPreferences.DiffPreferencesInfo.newBuilder();
       builder = setIfNotNull(builder, builder::setContext, info.context);
@@ -241,7 +280,8 @@
       return builder.build();
     }
 
-    public static DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
+    @Override
+    public DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
       DiffPreferencesInfo res = new DiffPreferencesInfo();
       res.context = proto.hasContext() ? proto.getContext() : null;
       res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
@@ -276,11 +316,28 @@
       return res;
     }
 
-    private DiffPreferencesInfoConverter() {}
+    @Override
+    public Parser<UserPreferences.DiffPreferencesInfo> getParser() {
+      return UserPreferences.DiffPreferencesInfo.parser();
+    }
+
+    @Override
+    public Class<UserPreferences.DiffPreferencesInfo> getProtoClass() {
+      return UserPreferences.DiffPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<DiffPreferencesInfo> getEntityClass() {
+      return DiffPreferencesInfo.class;
+    }
   }
 
-  public static final class EditPreferencesInfoConverter {
-    public static UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
+  public enum EditPreferencesInfoConverter
+      implements SafeProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
+    EDIT_PREFERENCES_INFO_CONVERTER;
+
+    @Override
+    public UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
       UserPreferences.EditPreferencesInfo.Builder builder =
           UserPreferences.EditPreferencesInfo.newBuilder();
       builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
@@ -300,7 +357,8 @@
       return builder.build();
     }
 
-    public static EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
+    @Override
+    public EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
       EditPreferencesInfo res = new EditPreferencesInfo();
       res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
       res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
@@ -320,7 +378,20 @@
       return res;
     }
 
-    private EditPreferencesInfoConverter() {}
+    @Override
+    public Parser<UserPreferences.EditPreferencesInfo> getParser() {
+      return UserPreferences.EditPreferencesInfo.parser();
+    }
+
+    @Override
+    public Class<UserPreferences.EditPreferencesInfo> getProtoClass() {
+      return UserPreferences.EditPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<EditPreferencesInfo> getEntityClass() {
+      return EditPreferencesInfo.class;
+    }
   }
 
   private static <ValueT, BuilderT extends Message.Builder> BuilderT setIfNotNull(
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index b3ce564..ae80fe4 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -42,40 +42,49 @@
   private final ObjectId baseTreeId;
   private final ImmutableList<? extends ObjectId> baseParents;
   private final Optional<ObjectInserter> objectInserter;
+  private final Optional<ObjectReader> objectReader;
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public static TreeCreator basedOn(RevCommit baseCommit) {
     requireNonNull(baseCommit, "baseCommit is required");
     return new TreeCreator(
-        baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()), Optional.empty());
+        baseCommit.getTree(),
+        ImmutableList.copyOf(baseCommit.getParents()),
+        Optional.empty(),
+        Optional.empty());
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
-  public static TreeCreator basedOn(RevCommit baseCommit, ObjectInserter objectInserter) {
+  public static TreeCreator basedOn(
+      RevCommit baseCommit, ObjectInserter objectInserter, ObjectReader objectReader) {
     requireNonNull(baseCommit, "baseCommit is required");
     return new TreeCreator(
         baseCommit.getTree(),
         ImmutableList.copyOf(baseCommit.getParents()),
-        Optional.of(objectInserter));
+        Optional.of(objectInserter),
+        Optional.of(objectReader));
   }
 
   public static TreeCreator basedOnTree(
       ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
     requireNonNull(baseTreeId, "baseTreeId is required");
-    return new TreeCreator(baseTreeId, baseParents, Optional.empty());
+    return new TreeCreator(baseTreeId, baseParents, Optional.empty(), Optional.empty());
   }
 
   public static TreeCreator basedOnEmptyTree() {
-    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of(), Optional.empty());
+    return new TreeCreator(
+        ObjectId.zeroId(), ImmutableList.of(), Optional.empty(), Optional.empty());
   }
 
   private TreeCreator(
       ObjectId baseTreeId,
       ImmutableList<? extends ObjectId> baseParents,
-      Optional<ObjectInserter> objectInserter) {
+      Optional<ObjectInserter> objectInserter,
+      Optional<ObjectReader> objectReader) {
     this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
     this.baseParents = baseParents;
     this.objectInserter = objectInserter;
+    this.objectReader = objectReader;
   }
 
   /**
@@ -138,14 +147,22 @@
   }
 
   private DirCache readBaseTree(Repository repository) throws IOException {
-    try (ObjectReader objectReader = repository.newObjectReader()) {
-      DirCache dirCache = DirCache.newInCore();
+    ObjectReader or = objectReader.orElseGet(() -> repository.newObjectReader());
+    try {
+      DirCache dirCache =
+          ObjectId.zeroId().equals(baseTreeId)
+              ? DirCache.newInCore()
+              : DirCache.read(or, baseTreeId);
       DirCacheBuilder dirCacheBuilder = dirCache.builder();
       if (!ObjectId.zeroId().equals(baseTreeId)) {
-        dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+        dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, or, baseTreeId);
       }
       dirCacheBuilder.finish();
       return dirCache;
+    } finally {
+      if (objectReader.isEmpty()) {
+        or.close();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index cd91745..49b5d2a6 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -60,4 +60,7 @@
   /** Whether we allow fix suggestions in HumanComments. */
   public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
       "GerritBackendFeature__allow_fix_suggestions_in_comments";
+
+  /** Whether etags should be disabled for change resources. */
+  public static final String DISABLE_CHANGE_ETAGS = "GerritBackendFeature__disable_change_etags";
 }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 307ec5c..60cbeed 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -39,15 +39,20 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Delayed;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.FutureTask;
+import java.util.concurrent.PriorityBlockingQueue;
 import java.util.concurrent.RunnableScheduledFuture;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
@@ -56,7 +61,9 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.lang3.mutable.MutableBoolean;
 import org.eclipse.jgit.lib.Config;
 
 /** Delayed execution of tasks using a background thread pool. */
@@ -88,6 +95,54 @@
     void onStop(Task<?> task);
   }
 
+  /**
+   * Register a TaskParker from a plugin like this:
+   *
+   * <p>bind(TaskListener.class).annotatedWith(Exports.named("MyParker")).to(MyParker.class);
+   */
+  public interface TaskParker extends TaskListener {
+    class NoOp extends TaskListener.NoOp implements TaskParker {
+      @Override
+      public boolean isReadyToStart(Task<?> task) {
+        return true;
+      }
+
+      @Override
+      public void onNotReadyToStart(Task<?> task) {}
+    }
+
+    /**
+     * Determine whether a {@link Task} is ready to run or whether it should get parked.
+     *
+     * <p>Tasks that are not ready to run will get parked and will not run until all {@link
+     * TaskParker}s return {@code true} from this method for the {@link Task}. This method may be
+     * called more than once, but will always be followed by a call to {@link
+     * #onNotReadyToStart(Task)} before being called again.
+     *
+     * <p>Resources should be acquired in this method via non-blocking means to avoid delaying the
+     * executor from calling {@link #onNotReadyToStart(Task)} on other {@link TaskParker}s holding
+     * resources.
+     *
+     * @param task the {@link Task} being considered for starting/parking
+     * @return a boolean indicating if the given {@link Task} is ready to run ({@code true}) or
+     *     should be parked ({@code false})
+     */
+    boolean isReadyToStart(Task<?> task);
+
+    /**
+     * This method will be called after this {@link TaskParker} returns {@code true} from {@link
+     * #isReadyToStart(Task)} and another {@link TaskParker} returns {@code false}, thus preventing
+     * the start.
+     *
+     * <p>Implementors should use this method to free any resources acquired in {@link
+     * #isReadyToStart(Task)} based on the expectation that the task would start. Those resources
+     * can be re-acquired when {@link #isReadyToStart(Task)} is called again later.
+     *
+     * @param task the {@link Task} that was prevented from starting by another {@link TaskParker}
+     */
+    void onNotReadyToStart(Task<?> task);
+  }
+
   public static class Lifecycle implements LifecycleListener {
     private final WorkQueue workQueue;
 
@@ -288,9 +343,75 @@
 
   /** An isolated queue. */
   private class Executor extends ScheduledThreadPoolExecutor {
+    private class ParkedTask implements Comparable<ParkedTask> {
+      public final CancellableCountDownLatch latch = new CancellableCountDownLatch(1);
+      public final Task<?> task;
+      private final Long priority = priorityGenerator.getAndIncrement();
+
+      public ParkedTask(Task<?> task) {
+        this.task = task;
+      }
+
+      @Override
+      public int compareTo(ParkedTask o) {
+        return priority.compareTo(o.priority);
+      }
+
+      /**
+       * Cancel a parked {@link Task}.
+       *
+       * <p>Tasks awaiting in {@link #onStart(Task)} to be un-parked can be interrupted using this
+       * method.
+       */
+      public void cancel() {
+        latch.cancel();
+      }
+
+      public boolean isEqualTo(Task task) {
+        return this.task.taskId == task.taskId;
+      }
+    }
+
+    private class CancellableCountDownLatch extends CountDownLatch {
+      protected volatile boolean cancelled = false;
+
+      public CancellableCountDownLatch(int count) {
+        super(count);
+      }
+
+      /**
+       * Unblocks threads which are waiting until the latch has counted down to zero.
+       *
+       * <p>If the current count is zero, then this method returns immediately.
+       *
+       * <p>If the current count is greater than zero, then it decrements until the count reaches
+       * zero and causes all threads waiting on the latch using {@link CountDownLatch#await()} to
+       * throw an {@link InterruptedException}.
+       */
+      public void cancel() {
+        if (getCount() == 0) {
+          return;
+        }
+        this.cancelled = true;
+        while (getCount() > 0) {
+          countDown();
+        }
+      }
+
+      @Override
+      public void await() throws InterruptedException {
+        super.await();
+        if (cancelled) {
+          throw new InterruptedException();
+        }
+      }
+    }
+
     private final ConcurrentHashMap<Integer, Task<?>> all;
     private final ConcurrentHashMap<Runnable, Long> nanosPeriodByRunnable;
     private final String queueName;
+    private final AtomicLong priorityGenerator = new AtomicLong();
+    private final PriorityBlockingQueue<ParkedTask> parked = new PriorityBlockingQueue<>();
 
     Executor(int corePoolSize, final String queueName) {
       super(
@@ -488,7 +609,17 @@
     }
 
     void remove(Task<?> task) {
-      all.remove(task.getTaskId(), task);
+      boolean isRemoved = all.remove(task.getTaskId(), task);
+      if (isRemoved && !listeners.isEmpty()) {
+        cancelIfParked(task);
+      }
+    }
+
+    void cancelIfParked(Task<?> task) {
+      Optional<ParkedTask> parkedTask = parked.stream().filter(p -> p.isEqualTo(task)).findFirst();
+      if (parkedTask.isPresent()) {
+        parkedTask.get().cancel();
+      }
     }
 
     Task<?> getTask(int id) {
@@ -503,12 +634,86 @@
       return all.values();
     }
 
+    public void waitUntilReadyToStart(Task<?> task) {
+      if (!listeners.isEmpty() && !isReadyToStart(task)) {
+        ParkedTask parkedTask = new ParkedTask(task);
+        parked.offer(parkedTask);
+        task.runningState.set(Task.State.PARKED);
+        incrementCorePoolSizeBy(1);
+        try {
+          parkedTask.latch.await();
+        } catch (InterruptedException e) {
+          logger.atSevere().withCause(e).log("Parked Task(%s) Interrupted", task);
+          parked.remove(parkedTask);
+        } finally {
+          incrementCorePoolSizeBy(-1);
+        }
+      }
+    }
+
     public void onStart(Task<?> task) {
       listeners.runEach(extension -> extension.get().onStart(task));
     }
 
     public void onStop(Task<?> task) {
       listeners.runEach(extension -> extension.get().onStop(task));
+      updateParked();
+    }
+
+    protected boolean isReadyToStart(Task<?> task) {
+      MutableBoolean isReady = new MutableBoolean(true);
+      Set<TaskParker> readyParkers = new HashSet<>();
+      listeners.runEach(
+          extension -> {
+            if (isReady.isTrue()) {
+              TaskListener listener = extension.get();
+              if (listener instanceof TaskParker) {
+                TaskParker parker = (TaskParker) listener;
+                if (parker.isReadyToStart(task)) {
+                  readyParkers.add(parker);
+                } else {
+                  isReady.setFalse();
+                }
+              }
+            }
+          });
+
+      if (isReady.isFalse()) {
+        listeners.runEach(
+            extension -> {
+              TaskListener listener = extension.get();
+              if (readyParkers.contains(listener)) {
+                ((TaskParker) listener).onNotReadyToStart(task);
+              }
+            });
+      }
+      return isReady.getValue();
+    }
+
+    public void updateParked() {
+      ParkedTask ready = parked.poll();
+      if (ready == null) {
+        return;
+      }
+      List<ParkedTask> notReady = new ArrayList<>();
+      while (ready != null && !isReadyToStart(ready.task)) {
+        notReady.add(ready);
+        ready = parked.poll();
+      }
+      parked.addAll(notReady);
+
+      if (ready != null) {
+        ready.latch.countDown();
+      }
+    }
+
+    public synchronized void incrementCorePoolSizeBy(int i) {
+      super.setCorePoolSize(getCorePoolSize() + i);
+    }
+
+    @Override
+    public synchronized void setCorePoolSize(int s) {
+      super.setCorePoolSize(s);
     }
   }
 
@@ -556,13 +761,14 @@
       // Ordered like this so ordinal matches the order we would
       // prefer to see tasks sorted in: done before running,
       // stopping before running, running before starting,
-      // starting before ready, ready before sleeping.
+      // starting before parked, parked before ready, ready before sleeping.
       //
       DONE,
       CANCELLED,
       STOPPING,
       RUNNING,
       STARTING,
+      PARKED,
       READY,
       SLEEPING,
       OTHER
@@ -694,12 +900,14 @@
 
     @Override
     public void run() {
-      if (runningState.compareAndSet(null, State.STARTING)) {
+      if (runningState.compareAndSet(null, State.READY)) {
         String oldThreadName = Thread.currentThread().getName();
         try {
+          Thread.currentThread().setName(oldThreadName + "[" + this + "]");
+          executor.waitUntilReadyToStart(this); // Transitions to PARKED while not ready to start
+          runningState.set(State.STARTING);
           executor.onStart(this);
           runningState.set(State.RUNNING);
-          Thread.currentThread().setName(oldThreadName + "[" + this + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index a5e20c6..68061bd 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -3192,7 +3192,10 @@
             // replace edit
             cmd =
                 new ReceiveCommand(
-                    edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+                    edit.get().getEditCommit(),
+                    newCommitId,
+                    edit.get().getRefName(),
+                    ReceiveCommand.Type.UPDATE_NONFASTFORWARD);
           } else {
             // delete old edit ref on rebase
             prev =
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
index 07d9b90..2f6c420 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -30,30 +30,31 @@
    * Creates a performance log record without meta data.
    *
    * @param operation the name of operation the is was performed
-   * @param durationMs the execution time in milliseconds
+   * @param durationNanos the execution time in nanoseconds
    * @return the performance log record
    */
-  public static PerformanceLogRecord create(String operation, long durationMs) {
+  public static PerformanceLogRecord create(String operation, long durationNanos) {
     return new AutoValue_PerformanceLogRecord(
-        operation, durationMs, Instant.now(), Optional.empty());
+        operation, durationNanos, Instant.now(), Optional.empty());
   }
 
   /**
    * Creates a performance log record with meta data.
    *
    * @param operation the name of operation the is was performed
-   * @param durationMs the execution time in milliseconds
+   * @param durationNanos the execution time in nanoseconds
    * @param metadata metadata
    * @return the performance log record
    */
-  public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
+  public static PerformanceLogRecord create(
+      String operation, long durationNanos, Metadata metadata) {
     return new AutoValue_PerformanceLogRecord(
-        operation, durationMs, Instant.now(), Optional.of(metadata));
+        operation, durationNanos, Instant.now(), Optional.of(metadata));
   }
 
   public abstract String operation();
 
-  public abstract long durationMs();
+  public abstract long durationNanos();
 
   public abstract Instant endTime();
 
@@ -61,9 +62,9 @@
 
   void writeTo(PerformanceLogger performanceLogger) {
     if (metadata().isPresent()) {
-      performanceLogger.log(operation(), durationMs(), endTime(), metadata().get());
+      performanceLogger.logNanos(operation(), durationNanos(), endTime(), metadata().get());
     } else {
-      performanceLogger.log(operation(), durationMs(), endTime());
+      performanceLogger.logNanos(operation(), durationNanos(), endTime());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
index bed53ba..2f90dfd 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogger.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import java.time.Instant;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Extension point for logging performance records.
@@ -36,6 +37,7 @@
    * @param operation operation that was performed
    * @param durationMs time that the execution of the operation took (in milliseconds)
    */
+  @Deprecated
   default void log(String operation, long durationMs, Instant endTime) {
     log(operation, durationMs, endTime, Metadata.empty());
   }
@@ -47,5 +49,27 @@
    * @param durationMs time that the execution of the operation took (in milliseconds)
    * @param metadata metadata
    */
+  @Deprecated
   void log(String operation, long durationMs, Instant endTime, Metadata metadata);
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationNanos time that the execution of the operation took (in nanoseconds)
+   */
+  default void logNanos(String operation, long durationNanos, Instant endTime) {
+    logNanos(operation, durationNanos, endTime, Metadata.empty());
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationNanos time that the execution of the operation took (in nanoseconds)
+   * @param metadata metadata
+   */
+  default void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata) {
+    log(operation, TimeUnit.NANOSECONDS.toMillis(durationNanos), endTime, metadata);
+  }
 }
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index fb698f7..a5eef65 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -188,10 +188,11 @@
     private TraceTimer(String operation) {
       this(
           () -> logger.atFine().log("Starting timer for %s", operation),
-          elapsedMs -> {
+          elapsedNanos -> {
             LoggingContext.getInstance()
-                .addPerformanceLogRecord(() -> PerformanceLogRecord.create(operation, elapsedMs));
-            logger.atFine().log("%s done (%d ms)", operation, elapsedMs);
+                .addPerformanceLogRecord(
+                    () -> PerformanceLogRecord.create(operation, elapsedNanos));
+            logger.atFine().log("%s done (%.2f ms)", operation, elapsedNanos / 1000000.0);
           });
     }
 
@@ -200,12 +201,13 @@
           () ->
               logger.atFine().log(
                   "Starting timer for %s (%s)", operation, metadata.toStringForLoggingLazy()),
-          elapsedMs -> {
+          elapsedNanos -> {
             LoggingContext.getInstance()
                 .addPerformanceLogRecord(
-                    () -> PerformanceLogRecord.create(operation, elapsedMs, metadata));
+                    () -> PerformanceLogRecord.create(operation, elapsedNanos, metadata));
             logger.atFine().log(
-                "%s (%s) done (%d ms)", operation, metadata.toStringForLoggingLazy(), elapsedMs);
+                "%s (%s) done (%.2f ms)",
+                operation, metadata.toStringForLoggingLazy(), elapsedNanos / 1000000.0);
           });
     }
 
@@ -219,7 +221,7 @@
     @Override
     public void close() {
       stopwatch.stop();
-      doneLogFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+      doneLogFn.accept(stopwatch.elapsed(TimeUnit.NANOSECONDS));
       RequestStateContext.abortIfCancelled();
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index a7da7ad..233998d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -54,6 +54,9 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
+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.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -595,8 +598,12 @@
 
   private void loadRobotComments() {
     if (robotCommentNotes == null) {
-      robotCommentNotes = new RobotCommentNotes(args, change);
-      robotCommentNotes.load();
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Load Robot Comments", Metadata.builder().changeId(change.getId().get()).build())) {
+        robotCommentNotes = new RobotCommentNotes(args, change);
+        robotCommentNotes.load();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 664ffa2..5d79d09 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -31,19 +31,26 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.Map;
 import java.util.Set;
+import javax.inject.Inject;
 
 /** Access control management for a user accessing a single change. */
-class ChangeControl {
+public class ChangeControl {
+  public interface Factory {
+    ChangeControl create(RefControl refControl, ChangeData changeData);
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final RefControl refControl;
   private final ChangeData changeData;
 
-  ChangeControl(RefControl refControl, ChangeData changeData) {
+  @Inject
+  protected ChangeControl(@Assisted RefControl refControl, @Assisted ChangeData changeData) {
     this.refControl = refControl;
     this.changeData = changeData;
   }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index 3f84dff..b5ec4c9 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -14,23 +14,32 @@
 
 package com.google.gerrit.server.permissions;
 
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.inject.AbstractModule;
+import com.google.inject.PrivateModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 /** Binds the default {@link PermissionBackend}. */
-public class DefaultPermissionBackendModule extends AbstractModule {
+public class DefaultPermissionBackendModule extends PrivateModule {
+
   @Override
   protected void configure() {
-    install(new LegacyControlsModule());
+    // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
+    install(new FactoryModuleBuilder().build(DefaultRefFilter.Factory.class));
+    installRefControlFactory();
+    installChangeControlFactory();
+    installProjectControlFactory();
+    // Expose only ProjectControl.Factory, so other modules can't use RefControl and ChangeControl.
+    expose(ProjectControl.Factory.class);
   }
 
-  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
-  public static class LegacyControlsModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
-      factory(ProjectControl.Factory.class);
-      factory(DefaultRefFilter.Factory.class);
-    }
+  protected void installProjectControlFactory() {
+    install(new FactoryModuleBuilder().build(ProjectControl.Factory.class));
+  }
+
+  protected void installChangeControlFactory() {
+    install(new FactoryModuleBuilder().build(ChangeControl.Factory.class));
+  }
+
+  protected void installRefControlFactory() {
+    install(new FactoryModuleBuilder().build(RefControl.Factory.class));
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index f179045..3d8ae4f 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -57,10 +57,10 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-class DefaultRefFilter {
+public class DefaultRefFilter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  interface Factory {
+  public interface Factory {
     DefaultRefFilter create(ProjectControl projectControl);
   }
 
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index ac9ac98..dbdd26f 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,6 +21,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelType;
@@ -261,6 +263,19 @@
       }
       return allowed;
     }
+
+    /**
+     * Additional filter for changes query for reducing the cardinality of the results for current
+     * user.
+     *
+     * @return additional query filter to add to all user's change queries, null if no filters are
+     *     required.
+     * @since 3.11
+     */
+    @UsedAt(UsedAt.Project.MODULE_VIRTUALHOST)
+    public @Nullable String filterQueryChanges() {
+      return null;
+    }
   }
 
   /** PermissionBackend scoped to a user and project. */
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index fab894e..9a6db5d 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -22,6 +22,7 @@
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -38,10 +39,13 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
@@ -59,53 +63,54 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /** Access control management for a user accessing a project's data. */
-class ProjectControl {
-  interface Factory {
+public class ProjectControl {
+  public interface Factory {
     ProjectControl create(CurrentUser who, ProjectState ps);
   }
 
   private final Set<AccountGroup.UUID> uploadGroups;
   private final Set<AccountGroup.UUID> receiveGroups;
   private final PermissionBackend permissionBackend;
-  private final RefVisibilityControl refVisibilityControl;
-  private final GitRepositoryManager repositoryManager;
   private final CurrentUser user;
   private final ProjectState state;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
-  private final ChangeData.Factory changeDataFactory;
   private final AllUsersName allUsersName;
+  private final RefControl.Factory refControlFactory;
+  private final ChangeControl.Factory changeControlFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
   private Boolean declaredOwner;
+  private Config cfg;
 
   @Inject
-  ProjectControl(
+  protected ProjectControl(
       @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       PermissionCollection.Factory permissionFilter,
       PermissionBackend permissionBackend,
-      RefVisibilityControl refVisibilityControl,
-      GitRepositoryManager repositoryManager,
       DefaultRefFilter.Factory refFilterFactory,
-      ChangeData.Factory changeDataFactory,
       AllUsersName allUsersName,
+      @GerritServerConfig Config cfg,
+      RefControl.Factory refControlFactory,
+      ChangeControl.Factory changeControlFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
-    this.refVisibilityControl = refVisibilityControl;
-    this.repositoryManager = repositoryManager;
     this.refFilterFactory = refFilterFactory;
-    this.changeDataFactory = changeDataFactory;
     this.allUsersName = allUsersName;
+    this.cfg = cfg;
+    this.refControlFactory = refControlFactory;
+    this.changeControlFactory = changeControlFactory;
     user = who;
     state = ps;
   }
@@ -115,7 +120,7 @@
   }
 
   ChangeControl controlFor(ChangeData cd) {
-    return new ChangeControl(controlForRef(cd.branchOrThrow()), cd);
+    return changeControlFactory.create(controlForRef(cd.branchOrThrow()), cd);
   }
 
   RefControl controlForRef(BranchNameKey ref) {
@@ -129,19 +134,17 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl =
-          new RefControl(
-              changeDataFactory, refVisibilityControl, this, repositoryManager, refName, relevant);
+      ctl = refControlFactory.create(this, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
   }
 
-  CurrentUser getUser() {
+  protected CurrentUser getUser() {
     return user;
   }
 
-  ProjectState getProjectState() {
+  protected ProjectState getProjectState() {
     return state;
   }
 
@@ -290,24 +293,29 @@
   }
 
   private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
-    boolean canPerform = false;
-    Set<String> patterns = allRefPatterns(permission);
-    if (patterns.contains(ALL)) {
-      // Only possible if granted on the pattern that
-      // matches every possible reference.  Check all
-      // patterns also have the permission.
-      //
-      for (String pattern : patterns) {
-        if (controlForRef(pattern).canPerform(permission)) {
-          canPerform = true;
-        } else if (ignore.contains(pattern)) {
-          continue;
-        } else {
-          return false;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "ProjectControl#canPerformOnAllRefs",
+            Metadata.builder().projectName(getProject().getName()).build())) {
+      boolean canPerform = false;
+      Set<String> patterns = allRefPatterns(permission);
+      if (patterns.contains(ALL)) {
+        // Only possible if granted on the pattern that
+        // matches every possible reference.  Check all
+        // patterns also have the permission.
+        //
+        for (String pattern : patterns) {
+          if (controlForRef(pattern).canPerform(permission)) {
+            canPerform = true;
+          } else if (ignore.contains(pattern)) {
+            continue;
+          } else {
+            return false;
+          }
         }
       }
+      return canPerform;
     }
-    return canPerform;
   }
 
   private Set<String> allRefPatterns(String permissionName) {
@@ -347,6 +355,13 @@
     }
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected boolean canUpdateConfigWithoutCreatingChange() {
+    // In google, the implementation use more complicated logic - this is why it is placed inside
+    // a ProjectControl.
+    return !cfg.getBoolean("gerrit", "requireChangeForConfigUpdate", false);
+  }
+
   private class ForProjectImpl extends ForProject {
     private String resourcePath;
 
@@ -468,6 +483,9 @@
         case READ_REFLOG:
         case WRITE_CONFIG:
           return isOwner();
+
+        case UPDATE_CONFIG_WITHOUT_CREATING_CHANGE:
+          return canUpdateConfigWithoutCreatingChange();
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index fc31e96..a4110ef 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -100,7 +100,10 @@
   READ_REFLOG,
 
   /** Can push to at least one reference within the repository. */
-  PUSH_AT_LEAST_ONE_REF("push to at least one ref");
+  PUSH_AT_LEAST_ONE_REF("push to at least one ref"),
+
+  /** Can use restapi to update project config without review. */
+  UPDATE_CONFIG_WITHOUT_CREATING_CHANGE("update config without creating a change using rest api");
 
   private final String description;
 
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 0aa68e2..ce8548d 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -37,6 +37,8 @@
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
@@ -47,8 +49,17 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-/** Manages access control for Git references (aka branches, tags). */
-class RefControl {
+/**
+ * Manages access control for Git references (aka branches, tags).
+ *
+ * <p>Do not use this class directly - instead use {@link ProjectControl} class. This class is
+ * public only because it is extended in google-owned implementation.
+ */
+public class RefControl {
+  public interface Factory {
+    RefControl create(ProjectControl projectControl, String ref, PermissionCollection relevant);
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
@@ -67,22 +78,23 @@
   private Boolean canForgeCommitter;
   private Boolean hasReadPermissionOnRef;
 
-  RefControl(
+  @Inject
+  protected RefControl(
       ChangeData.Factory changeDataFactory,
       RefVisibilityControl refVisibilityControl,
-      ProjectControl projectControl,
       GitRepositoryManager repositoryManager,
-      String ref,
-      PermissionCollection relevant) {
+      @Assisted ProjectControl projectControl,
+      @Assisted String ref,
+      @Assisted PermissionCollection relevant) {
     this.changeDataFactory = changeDataFactory;
     this.refVisibilityControl = refVisibilityControl;
-    this.projectControl = projectControl;
     this.repositoryManager = repositoryManager;
+    this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
   }
 
-  ProjectControl getProjectControl() {
+  protected ProjectControl getProjectControl() {
     return projectControl;
   }
 
@@ -90,6 +102,10 @@
     return projectControl.getUser();
   }
 
+  protected String getRefName() {
+    return refName;
+  }
+
   /** Is this user a ref owner? */
   boolean isOwner() {
     if (owner == null) {
@@ -139,7 +155,7 @@
   }
 
   /** Returns true if this user can submit patch sets to this ref */
-  boolean canSubmit(boolean isChangeOwner) {
+  protected boolean canSubmit(boolean isChangeOwner) {
     if (RefNames.REFS_CONFIG.equals(refName)) {
       // Always allow project owners to submit configuration changes.
       // Submitting configuration changes modifies the access control
@@ -297,7 +313,7 @@
     return pr.getAction() == Action.BLOCK && (!pr.getForce() || withForce);
   }
 
-  private PermissionRange toRange(String permissionName, boolean isChangeOwner) {
+  protected PermissionRange toRange(String permissionName, boolean isChangeOwner) {
     int blockAllowMin = Integer.MIN_VALUE, blockAllowMax = Integer.MAX_VALUE;
 
     projectLoop:
@@ -444,7 +460,7 @@
                   pr.getGroup().getUUID().get(),
                   pr);
           LoggingContext.getInstance().addAclLogRecord(logMessage);
-          logger.atFine().log(logMessage);
+          logger.atFine().log("%s", logMessage);
         }
         return true;
       }
@@ -460,7 +476,7 @@
               projectControl.getProject().getName(),
               refName);
       LoggingContext.getInstance().addAclLogRecord(logMessage);
-      logger.atFine().log(logMessage);
+      logger.atFine().log("%s", logMessage);
     }
     return false;
   }
@@ -609,98 +625,98 @@
     public BooleanCondition testCond(RefPermission perm) {
       return new PermissionBackendCondition.ForRef(this, perm, getUser());
     }
+  }
 
-    private boolean can(RefPermission perm) throws PermissionBackendException {
-      switch (perm) {
-        case READ:
-          /* Internal users such as plugin users should be able to read all refs. */
-          if (getUser().isInternalUser()) {
-            return true;
-          }
-          if (refName.startsWith(Constants.R_TAGS)) {
-            return isTagVisible();
-          }
-          return refVisibilityControl.isVisible(projectControl, refName);
-        case CREATE:
-          // TODO This isn't an accurate test.
-          return canPerform(refPermissionName(perm));
-        case DELETE:
-          return canDelete();
-        case UPDATE:
-          return canUpdate();
-        case FORCE_UPDATE:
-          return canForceUpdate();
-        case SET_HEAD:
-          return projectControl.isOwner();
+  protected boolean can(RefPermission perm) throws PermissionBackendException {
+    switch (perm) {
+      case READ:
+        /* Internal users such as plugin users should be able to read all refs. */
+        if (getUser().isInternalUser()) {
+          return true;
+        }
+        if (refName.startsWith(Constants.R_TAGS)) {
+          return isTagVisible();
+        }
+        return refVisibilityControl.isVisible(projectControl, refName);
+      case CREATE:
+        // TODO This isn't an accurate test.
+        return canPerform(refPermissionName(perm));
+      case DELETE:
+        return canDelete();
+      case UPDATE:
+        return canUpdate();
+      case FORCE_UPDATE:
+        return canForceUpdate();
+      case SET_HEAD:
+        return projectControl.isOwner();
 
-        case FORGE_AUTHOR:
-          return canForgeAuthor();
-        case FORGE_COMMITTER:
-          return canForgeCommitter();
-        case FORGE_SERVER:
-          return canForgeGerritServerIdentity();
-        case MERGE:
-          return canUploadMerges();
+      case FORGE_AUTHOR:
+        return canForgeAuthor();
+      case FORGE_COMMITTER:
+        return canForgeCommitter();
+      case FORGE_SERVER:
+        return canForgeGerritServerIdentity();
+      case MERGE:
+        return canUploadMerges();
 
-        case CREATE_CHANGE:
-          return canUpload();
+      case CREATE_CHANGE:
+        return canUpload();
 
-        case CREATE_TAG:
-        case CREATE_SIGNED_TAG:
-          return canPerform(refPermissionName(perm));
+      case CREATE_TAG:
+      case CREATE_SIGNED_TAG:
+        return canPerform(refPermissionName(perm));
 
-        case UPDATE_BY_SUBMIT:
-          return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
+      case UPDATE_BY_SUBMIT:
+        return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
 
-        case READ_PRIVATE_CHANGES:
-          return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+      case READ_PRIVATE_CHANGES:
+        return canPerform(Permission.VIEW_PRIVATE_CHANGES);
 
-        case READ_CONFIG:
-          return projectControl
-              .controlForRef(RefNames.REFS_CONFIG)
-              .canPerform(RefPermission.READ.name());
-        case WRITE_CONFIG:
-          return isOwner();
+      case READ_CONFIG:
+        return projectControl
+            .controlForRef(RefNames.REFS_CONFIG)
+            .canPerform(RefPermission.READ.name());
+      case WRITE_CONFIG:
+        return isOwner();
 
-        case SKIP_VALIDATION:
-          return canForgeAuthor()
-              && canForgeCommitter()
-              && canForgeGerritServerIdentity()
-              && canUploadMerges();
-      }
-      throw new PermissionBackendException(perm + " unsupported");
+      case SKIP_VALIDATION:
+        return canForgeAuthor()
+            && canForgeCommitter()
+            && canForgeGerritServerIdentity()
+            && canUploadMerges();
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
+
+  private boolean isTagVisible() throws PermissionBackendException {
+    if (projectControl.asForProject().test(ProjectPermission.READ)) {
+      // The user has READ on refs/* with no effective block permission. This is the broadest
+      // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
+      // so we have to assume that these users can see all tags because there could be tags that
+      // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+      // matches Gerrit's historic behavior.
+      // This makes it so that these users could see commits that they can't see otherwise
+      // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+      // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+      // is a negligible risk.
+      return true;
     }
 
-    private boolean isTagVisible() throws PermissionBackendException {
-      if (projectControl.asForProject().test(ProjectPermission.READ)) {
-        // The user has READ on refs/* with no effective block permission. This is the broadest
-        // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
-        // so we have to assume that these users can see all tags because there could be tags that
-        // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
-        // matches Gerrit's historic behavior.
-        // This makes it so that these users could see commits that they can't see otherwise
-        // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
-        // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
-        // is a negligible risk.
-        return true;
+    try (Repository repo =
+        repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
+      // Tag visibility requires going through RefFilter because it entails loading all taggable
+      // refs and filtering them all by visibility.
+      Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
+      if (resolvedRef == null) {
+        return false;
       }
-
-      try (Repository repo =
-          repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
-        // Tag visibility requires going through RefFilter because it entails loading all taggable
-        // refs and filtering them all by visibility.
-        Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
-        if (resolvedRef == null) {
-          return false;
-        }
-        return projectControl.asForProject()
-            .filter(
-                ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
-            .stream()
-            .anyMatch(r -> refName.equals(r.getName()));
-      } catch (IOException e) {
-        throw new PermissionBackendException(e);
-      }
+      return projectControl.asForProject()
+          .filter(
+              ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .anyMatch(r -> refName.equals(r.getName()));
+    } catch (IOException e) {
+      throw new PermissionBackendException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index c2d1139..bae1fef 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -38,7 +38,7 @@
  * authoritatively tell if a ref is accessible by a user.
  */
 @Singleton
-class RefVisibilityControl {
+public class RefVisibilityControl {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 10e5b59..8cfc6f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -52,6 +52,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
+import com.google.inject.util.Modules;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.ParameterizedType;
 import java.util.Collections;
@@ -175,16 +176,7 @@
     final Module db = copy(dbInjector);
     final Module cm = copy(cfgInjector);
     final Module sm = copy(sysInjector);
-    sysModule =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            install(copyConfigModule);
-            install(db);
-            install(cm);
-            install(sm);
-          }
-        };
+    sysModule = Modules.combine(copyConfigModule, db, cm, sm);
   }
 
   public void setSshInjector(Injector injector) {
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index c7d62d1..fb7cbe2 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -171,7 +171,7 @@
   @Override
   protected boolean canReload() {
     Attributes main = manifest.getMainAttributes();
-    String apiModule = main.getValue("Gerrit-ApiModule");
+    String apiModule = main.getValue(API_MODULE);
     if (apiModule != null) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index b940ccc..4bd195c 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -141,7 +141,7 @@
             .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
-            .maximumWeight(1)
+            .maximumWeight(4)
             .loader(Lister.class);
 
         bind(ProjectCacheImpl.class);
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 3fda87a..ea62d7d 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -45,10 +46,16 @@
    *
    * @throws AuthException if this user is not allowed to remove this approval.
    * @throws PermissionBackendException on failure of permission checks.
+   * @throws ResourceConflictException if the approval cannot be removed because the change is
+   *     merged
    */
   public void checkRemoveReviewer(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
-      throws PermissionBackendException, AuthException {
+      throws PermissionBackendException, AuthException, ResourceConflictException {
+    if (notes.getChange().isMerged() && approval.value() != 0) {
+      throw new ResourceConflictException("cannot remove votes from merged change");
+    }
+
     checkRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
   }
 
@@ -82,35 +89,43 @@
   public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
       throws PermissionBackendException {
-    if (canRemoveReviewerWithoutPermissionCheck(
-        permissionBackend, cd.change(), currentUser, reviewer, value)) {
-      return true;
-    }
-    return permissionBackend.user(currentUser).change(cd).test(ChangePermission.REMOVE_REVIEWER);
-  }
-
-  private void checkRemoveReviewer(
-      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int val)
-      throws PermissionBackendException, AuthException {
-    if (canRemoveReviewerWithoutPermissionCheck(
-        permissionBackend, notes.getChange(), currentUser, reviewer, val)) {
-      return;
-    }
-
-    permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
-  }
-
-  private static boolean canRemoveReviewerWithoutPermissionCheck(
-      PermissionBackend permissionBackend,
-      Change change,
-      CurrentUser currentUser,
-      Account.Id reviewer,
-      int value)
-      throws PermissionBackendException {
-    if (change.isMerged() && value != 0) {
+    if (cd.change().isMerged() && value != 0) {
       return false;
     }
 
+    if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
+      return true;
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
+    PermissionBackend.ForProject forProject = withUser.project(cd.project());
+    return (forProject.ref(cd.change().getDest().branch()).test(RefPermission.WRITE_CONFIG)
+            || withUser.test(GlobalPermission.ADMINISTRATE_SERVER))
+        || withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  private void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException, AuthException {
+    if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, value)) {
+      return;
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
+    PermissionBackend.ForProject forProject = withUser.project(notes.getProjectName());
+    if (forProject.ref(notes.getChange().getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
+      return;
+    }
+    permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  public static boolean canRemoveReviewerWithoutPermissionCheck(
+      Change change, CurrentUser currentUser, Account.Id reviewer, int value) {
     if (currentUser.isIdentifiedUser()) {
       Account.Id aId = currentUser.getAccountId();
       if (aId.equals(reviewer)) {
@@ -120,14 +135,6 @@
       }
     }
 
-    // Users with the remove reviewer permission, the branch owner, project
-    // owner and site admin can remove anyone
-    PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
-    PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
-        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      return true;
-    }
     return false;
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
index 95c287a..ecd1fe2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerId;
+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.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
@@ -68,20 +71,24 @@
     }
 
     int changeNum = changeNumId.get();
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "ChangeNumberBitmapMaskAlgorithm", Metadata.builder().changeId(changeNum).build())) {
+      if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
+        throw new IllegalArgumentException(
+            String.format(
+                "Change number %d is too large to be converted into a virtual id", changeNum));
+      }
 
-    if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
-      throw new IllegalArgumentException(
-          String.format(
-              "Change number %d is too large to be converted into a virtual id", changeNum));
+      Integer encodedServerId = serverIdCodes.get(changeServerId);
+      if (encodedServerId == null) {
+        throw new IllegalArgumentException(
+            String.format(
+                "ServerId %s is not part of the GerritImportedServerIds", changeServerId));
+      }
+      int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+
+      return Change.id(virtualId);
     }
-
-    Integer encodedServerId = serverIdCodes.get(changeServerId);
-    if (encodedServerId == null) {
-      throw new IllegalArgumentException(
-          String.format("ServerId %s is not part of the GerritImportedServerIds", changeServerId));
-    }
-    int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
-
-    return Change.id(virtualId);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index 73991c9..359393e 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi;
 
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.restapi.access.AccessRestApiModule;
 import com.google.gerrit.server.restapi.account.AccountRestApiModule;
@@ -42,5 +43,6 @@
     install(new PluginRestApiModule());
     install(new ProjectRestApiModule());
     install(new ProjectRestApiModule.BatchModule());
+    install(new ChangeCleanupRunnerModule());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index b1af85e..1c7b1ca 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -63,7 +62,6 @@
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final ExternalIds externalIds;
   private final ExternalIdFactory externalIdFactory;
 
   @Inject
@@ -71,12 +69,10 @@
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      ExternalIds externalIds,
       ExternalIdFactory externalIdFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.externalIds = externalIds;
     this.externalIdFactory = externalIdFactory;
   }
 
@@ -99,7 +95,7 @@
             .update(
                 "Set Preferred Email via API",
                 user.getAccountId(),
-                (a, u) -> {
+                (r, a, u) -> {
                   if (preferredEmail.equals(a.account().preferredEmail())) {
                     alreadyPreferred.set(true);
                   } else {
@@ -125,7 +121,7 @@
                       if (user.hasEmailAddress(preferredEmail)) {
                         // but Realm says the user is allowed to use this email
                         ImmutableSet<ExternalId> existingExtIdsWithThisEmail =
-                            externalIds.byEmail(preferredEmail);
+                            r.externalIdsReader().byEmail(preferredEmail);
                         if (!existingExtIdsWithThisEmail.isEmpty()) {
                           // but the email is already assigned to another account
                           logger.atWarning().log(
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 7d8c793..8d5247d 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -16,117 +16,88 @@
 
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 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.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
 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 java.util.Optional;
 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.patch.PatchApplier.Result.Error;
 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;
   private final ProjectCache projectCache;
   private final ChangeUtil changeUtil;
+  private final PatchSetCreator patchSetCreator;
 
   @Inject
   ApplyPatch(
-      ChangeJson.Factory jsonFactory,
       ContributorAgreementsChecker contributorAgreements,
-      Provider<IdentifiedUser> user,
       GitRepositoryManager gitManager,
-      BatchUpdate.Factory batchUpdateFactory,
-      PatchSetInserter.Factory patchSetInserterFactory,
       Provider<InternalChangeQuery> queryProvider,
-      @GerritPersonIdent PersonIdent myIdent,
       ProjectCache projectCache,
-      ChangeUtil changeUtil) {
-    this.jsonFactory = jsonFactory;
+      ChangeUtil changeUtil,
+      PatchSetCreator patchSetCreator) {
     this.contributorAgreements = contributorAgreements;
-    this.user = user;
     this.gitManager = gitManager;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.patchSetInserterFactory = patchSetInserterFactory;
     this.queryProvider = queryProvider;
-    this.serverZoneId = myIdent.getZoneId();
     this.projectCache = projectCache;
     this.changeUtil = changeUtil;
+    this.patchSetCreator = patchSetCreator;
   }
 
   @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();
-
     if (input == null || input.patch == null || input.patch.patch == null) {
       throw new BadRequestException("patch required");
     }
 
+    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
@@ -140,22 +111,7 @@
             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()));
-      }
+      patchSetCreator.validateChangeCanBeAppended(destChange, destBranch);
 
       if (!Strings.isNullOrEmpty(input.base) && Boolean.TRUE.equals(input.amend)) {
         throw new BadRequestException("amend only works with existing revisions. omit base.");
@@ -173,7 +129,8 @@
         if (latestPatchset.getParentCount() != 1) {
           throw new BadRequestException(
               String.format(
-                  "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
+                  "Cannot parse base commit for a change with none or multiple parents. Change ID:"
+                      + " %s.",
                   destChange.getId()));
         }
         if (Boolean.TRUE.equals(input.amend)) {
@@ -184,50 +141,41 @@
           parents = ImmutableList.of(baseCommit);
         }
       }
+
+      List<ListChangesOption> opts = input.responseFormatOptions;
+      if (opts == null) {
+        opts = ImmutableList.of();
+      }
+
       PatchApplier.Result applyResult =
           ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
-      ObjectId treeId = applyResult.getTreeId();
 
-      Instant now = TimeUtil.now();
-      PersonIdent committerIdent =
-          Optional.ofNullable(latestPatchset.getCommitterIdent())
-              .map(
-                  ident ->
-                      user.get()
-                          .newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
-                          .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId)))
-              .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId));
-      PersonIdent authorIdent =
-          input.author == null
-              ? committerIdent
-              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
       String commitMessage =
           buildFullCommitMessage(
               project,
               latestPatchset,
               input,
-              ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
+              ApplyPatchUtil.getResultPatch(
+                  repo, reader, baseCommit, revWalk.lookupTree(applyResult.getTreeId())),
               applyResult.getErrors());
 
-      ObjectId appliedCommit =
-          CommitUtil.createCommitWithTree(
-              oi, authorIdent, committerIdent, parents, 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());
+      ChangeInfo changeInfo =
+          patchSetCreator.createPatchSetWithSuppliedTree(
+              project,
+              destChange,
+              latestPatchset,
+              parents,
+              input.author,
+              opts,
+              repo,
+              oi,
+              revWalk,
+              applyResult.getTreeId(),
+              commitMessage);
+      if (changeInfo.containsGitConflicts == null
+          && applyResult.getErrors().stream().anyMatch(Error::isGitConflict)) {
+        changeInfo.containsGitConflicts = true;
       }
-      List<ListChangesOption> opts = input.responseFormatOptions;
-      if (opts == null) {
-        opts = ImmutableList.of();
-      }
-      ChangeInfo changeInfo = jsonFactory.create(opts).format(resultChange);
       return Response.ok(changeInfo);
     }
   }
@@ -262,7 +210,7 @@
     }
     String commitMessage =
         ApplyPatchUtil.buildCommitMessage(
-            messageWithNoFooters, footerLines, input.patch.patch, resultPatch, errors);
+            messageWithNoFooters, footerLines, input.patch, resultPatch, errors);
 
     boolean changeIdRequired =
         projectCache
@@ -275,28 +223,6 @@
     return commitMessage;
   }
 
-  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;
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
index 56b3842..8b8aaa6 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -41,6 +41,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.patch.Patch;
 import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -80,6 +81,9 @@
     }
     try {
       PatchApplier applier = new PatchApplier(repo, tip, oi);
+      if (Boolean.TRUE.equals(input.allowConflicts)) {
+        applier.allowConflicts();
+      }
       PatchApplier.Result applyResult = applier.applyPatch(patch);
       return applyResult;
     } catch (IOException e) {
@@ -105,7 +109,7 @@
    *
    * @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 patchInput API input that triggered this action
    * @param resultPatch to validate accuracy for
    * @return the commit message
    * @throws BadRequestException if the commit message cannot be sanitized
@@ -113,7 +117,7 @@
   public static String buildCommitMessage(
       String message,
       List<FooterLine> footerLines,
-      String originalPatch,
+      ApplyPatchInput patchInput,
       String resultPatch,
       List<PatchApplier.Result.Error> errors)
       throws BadRequestException {
@@ -121,13 +125,19 @@
 
     boolean appendOriginalPatch = false;
     boolean appendResultPatch = false;
-    String decodedOriginalPatch = decodeIfNecessary(originalPatch);
+    String decodedOriginalPatch = decodeIfNecessary(patchInput.patch);
     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;
+      if (errors.stream().allMatch(Error::isGitConflict)) {
+        res.append(
+            "\n\nATTENTION: Conflicts occurred while applying the patch.\n"
+                + "Please resolve conflict markers.");
+      } else {
+        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);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index e254bfc..746313b 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -37,7 +37,6 @@
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
 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.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -115,6 +114,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TreeFormatter;
 import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
@@ -202,7 +202,8 @@
   @FunctionalInterface
   public interface CommitTreeSupplier {
     @NonNull
-    ObjectId get(Repository repo, ObjectInserter oi, ChangeInput input, RevCommit mergeTip)
+    ObjectId get(
+        Repository repo, ObjectInserter oi, ObjectReader or, ChangeInput input, RevCommit mergeTip)
         throws IOException, RestApiException;
   }
 
@@ -439,6 +440,7 @@
         String commitMessage = getCommitMessage(input.subject, me);
 
         CodeReviewCommit c;
+        boolean hasGitConflicts = false;
         if (input.merge != null) {
           // create a merge commit
           c =
@@ -459,14 +461,38 @@
           }
         } 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();
+          logger.atFine().log("tree ID after applying patch: %s", treeId.name());
+          String appliedPatchCommitMessage =
+              getCommitMessage(
+                  ApplyPatchUtil.buildCommitMessage(
+                      input.subject,
+                      ImmutableList.of(),
+                      input.patch,
+                      ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
+                      applyResult.getErrors()),
+                  me);
           c =
-              createCommitWithPatch(
-                  git, reader, oi, rw, mergeTip, input.patch, input.subject, author, committer, me);
+              rw.parseCommit(
+                  CommitUtil.createCommitWithTree(
+                      oi,
+                      author,
+                      committer,
+                      ImmutableList.of(mergeTip),
+                      appliedPatchCommitMessage,
+                      treeId));
+          hasGitConflicts = applyResult.getErrors().stream().anyMatch(Error::isGitConflict);
         } else if (commitTreeSupplier.isPresent()) {
           c =
               createCommitWithSuppliedTree(
                   git,
                   oi,
+                  reader,
                   rw,
                   mergeTip,
                   input,
@@ -523,7 +549,8 @@
           opts = ImmutableList.of();
         }
         ChangeInfo changeInfo = jsonFactory.create(opts).format(ins.getChange());
-        changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
+        changeInfo.containsGitConflicts =
+            (!c.getFilesWithGitConflicts().isEmpty() || hasGitConflicts) ? true : null;
         return changeInfo;
       } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
         throw new BadRequestException(e.getMessage());
@@ -661,46 +688,10 @@
             oi, authorIdent, committerIdent, parents, commitMessage, treeId));
   }
 
-  private CodeReviewCommit createCommitWithPatch(
-      Repository repo,
-      ObjectReader reader,
-      ObjectInserter oi,
-      CodeReviewRevWalk rw,
-      RevCommit mergeTip,
-      ApplyPatchInput patch,
-      String subject,
-      PersonIdent authorIdent,
-      PersonIdent committerIdent,
-      IdentifiedUser me)
-      throws IOException, RestApiException {
-    if (mergeTip == null) {
-      throw new BadRequestException("Cannot apply patch on top of an empty tree.");
-    }
-    PatchApplier.Result applyResult = ApplyPatchUtil.applyPatch(repo, oi, patch, mergeTip);
-    ObjectId treeId = applyResult.getTreeId();
-    logger.atFine().log("tree ID after applying patch: %s", treeId.name());
-    String appliedPatchCommitMessage =
-        getCommitMessage(
-            ApplyPatchUtil.buildCommitMessage(
-                subject,
-                ImmutableList.of(),
-                patch.patch,
-                ApplyPatchUtil.getResultPatch(repo, reader, mergeTip, rw.lookupTree(treeId)),
-                applyResult.getErrors()),
-            me);
-    return rw.parseCommit(
-        CommitUtil.createCommitWithTree(
-            oi,
-            authorIdent,
-            committerIdent,
-            ImmutableList.of(mergeTip),
-            appliedPatchCommitMessage,
-            treeId));
-  }
-
   private static CodeReviewCommit createCommitWithSuppliedTree(
       Repository repo,
       ObjectInserter oi,
+      ObjectReader or,
       CodeReviewRevWalk rw,
       RevCommit mergeTip,
       ChangeInput input,
@@ -712,7 +703,7 @@
     if (mergeTip == null) {
       throw new BadRequestException("`CommitTreeSupplier` cannot be used on top of an empty tree.");
     }
-    ObjectId treeId = commitTreeSupplier.get(repo, oi, input, mergeTip);
+    ObjectId treeId = commitTreeSupplier.get(repo, oi, or, input, mergeTip);
     return rw.parseCommit(
         CommitUtil.createCommitWithTree(
             oi, authorIdent, committerIdent, ImmutableList.of(mergeTip), commitMessage, treeId));
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index b3d7fa2..4c6ee9a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+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;
@@ -79,6 +80,9 @@
     ReviewerResource r = rsrc.getReviewer();
     Change change = r.getChange();
 
+    if (change.isMerged()) {
+      throw new ResourceConflictException("cannot remove votes from merged change");
+    }
     if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
       throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index d126d8a..c1a8f45 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -37,6 +37,9 @@
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.MissingMetaObjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -45,6 +48,7 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -104,9 +108,14 @@
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
     try {
-      Change change = rsrc.getChange();
-      ObjectId changeMetaRevId = getMetaRevId(change);
-      return Response.withMustRevalidate(newChangeJson().format(change, changeMetaRevId));
+      Optional<ObjectId> changeMetaRevId = getMetaRevId(rsrc.getChange());
+      ChangeInfo changeInfo;
+      if (changeMetaRevId.isPresent()) {
+        changeInfo = newChangeJson().format(rsrc.getChange(), changeMetaRevId.get());
+      } else {
+        changeInfo = newChangeJson().format(rsrc.getChangeData());
+      }
+      return Response.withMustRevalidate(changeInfo);
     } catch (MissingMetaObjectException e) {
       throw new PreconditionFailedException(e.getMessage());
     }
@@ -116,22 +125,25 @@
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
-  @Nullable
-  private ObjectId getMetaRevId(Change change) throws RestApiException {
+  private Optional<ObjectId> getMetaRevId(Change change) throws RestApiException {
     if (metaRevId.isEmpty()) {
-      return null;
+      return Optional.empty();
     }
 
-    // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
-    // without having to fetch the entire /meta ref. If we do so, we have to be careful that
-    // the error messages can't be abused to fetch hidden data.
-    ObjectId metaRevObjectId;
-    try {
-      metaRevObjectId = ObjectId.fromString(metaRevId);
-    } catch (InvalidObjectIdException e) {
-      throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get meta rev ID", Metadata.builder().changeId(change.getId().get()).build())) {
+      // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
+      // without having to fetch the entire /meta ref. If we do so, we have to be careful that
+      // the error messages can't be abused to fetch hidden data.
+      ObjectId metaRevObjectId;
+      try {
+        metaRevObjectId = ObjectId.fromString(metaRevId);
+      } catch (InvalidObjectIdException e) {
+        throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+      }
+      return verifyMetaId(change, metaRevObjectId);
     }
-    return verifyMetaId(change, metaRevObjectId);
   }
 
   private ChangeJson newChangeJson() {
@@ -144,10 +156,10 @@
         cds, this, Streams.stream(pdiFactories.entries()));
   }
 
-  @Nullable
-  private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
+  private Optional<ObjectId> verifyMetaId(Change change, @Nullable ObjectId id)
+      throws RestApiException {
     if (id == null) {
-      return null;
+      return Optional.empty();
     }
 
     String changeMetaRefName = RefNames.changeMetaRef(change.getId());
@@ -159,7 +171,7 @@
       rw.markStart(tip);
       for (RevCommit rev : rw) {
         if (id.equals(rev)) {
-          return id;
+          return Optional.of(id);
         }
       }
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index d8946a7..749a241 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -51,6 +52,10 @@
   @Option(name = "--path")
   private String path;
 
+  /** 1-based index of the parent's position in the commit object. */
+  @Option(name = "--parent", metaVar = "parent-number")
+  private Integer parentNum;
+
   @Inject
   GetPatch(GitRepositoryManager repoManager) {
     this.repoManager = repoManager;
@@ -58,7 +63,8 @@
 
   @Override
   public Response<BinaryResult> apply(RevisionResource rsrc)
-      throws ResourceConflictException, IOException, ResourceNotFoundException {
+      throws BadRequestException, ResourceConflictException, IOException,
+          ResourceNotFoundException {
     final Repository repo = repoManager.openRepository(rsrc.getProject());
     boolean close = true;
     try {
@@ -67,12 +73,16 @@
       try {
         final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         RevCommit[] parents = commit.getParents();
-        if (parents.length > 1) {
+        if (parentNum == null && parents.length > 1) {
           throw new ResourceConflictException("Revision has more than 1 parent.");
-        } else if (parents.length == 0) {
+        }
+        if (parents.length == 0) {
           throw new ResourceConflictException("Revision has no parent.");
         }
-        final RevCommit base = parents[0];
+        if (parentNum != null && (parentNum < 1 || parentNum > parents.length)) {
+          throw new BadRequestException(String.format("invalid parent number: %d", parentNum));
+        }
+        final RevCommit base = parents[parentNum == null ? 0 : parentNum - 1];
         rw.parseBody(base);
 
         bin =
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
new file mode 100644
index 0000000..60c8e89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 static java.util.Objects.requireNonNull;
+
+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.entities.Project;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.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.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+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 java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Optional;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** A utility class for creating a patch set on an existing change. */
+@Singleton
+public class PatchSetCreator {
+  private final Provider<IdentifiedUser> ident;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final ChangeJson.Factory jsonFactory;
+  private final ZoneId serverZoneId;
+
+  @Inject
+  PatchSetCreator(
+      Provider<IdentifiedUser> ident,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      @GerritPersonIdent PersonIdent myIdent,
+      ChangeJson.Factory jsonFactory) {
+    this.ident = ident;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.serverZoneId = myIdent.getZoneId();
+    this.jsonFactory = jsonFactory;
+  }
+
+  public ChangeInfo createPatchSetWithSuppliedTree(
+      Project.NameKey project,
+      ChangeData destChange,
+      RevCommit latestPatchset,
+      List<RevCommit> parents,
+      @Nullable AccountInput author,
+      List<ListChangesOption> outputOptions,
+      Repository repo,
+      ObjectInserter oi,
+      CodeReviewRevWalk revWalk,
+      ObjectId commitTree,
+      String commitMessage)
+      throws IOException, RestApiException, UpdateException {
+    requireNonNull(destChange);
+    requireNonNull(latestPatchset);
+    requireNonNull(parents);
+    requireNonNull(outputOptions);
+
+    Instant now = TimeUtil.now();
+    PersonIdent committerIdent =
+        Optional.ofNullable(latestPatchset.getCommitterIdent())
+            .map(
+                id ->
+                    ident
+                        .get()
+                        .newCommitterIdent(id.getEmailAddress(), now, serverZoneId)
+                        .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId)))
+            .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId));
+    PersonIdent authorIdent =
+        author == null
+            ? committerIdent
+            : new PersonIdent(author.name, author.email, now, serverZoneId);
+
+    ObjectId appliedCommit =
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, parents, commitMessage, commitTree);
+    CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+    oi.flush();
+
+    Change resultChange;
+    try (BatchUpdate bu = batchUpdateFactory.create(project, ident.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());
+    }
+
+    return jsonFactory.create(outputOptions).format(resultChange);
+  }
+
+  public void validateChangeCanBeAppended(@Nullable ChangeData destChange, BranchNameKey destBranch)
+      throws PreconditionFailedException {
+    if (destChange == null) {
+      throw new PreconditionFailedException(
+          "cannot write a patch set 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()));
+    }
+  }
+
+  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 String.format("Uploaded patch set %s.", psId.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 490ff490..511cb17 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -425,11 +425,14 @@
   }
 
   /**
-   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
-   * neither in {@code existingComments} nor in {@code drafts}.
+   * Returns the subset of {@code inputComments} that should be added to the change.
    *
-   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-   * removed.
+   * <p>If the matching comment (with the same id) already exists in {@code existingComments} then
+   * the comment is filtered out. This assumes that the comment has been already published earlier.
+   *
+   * <p>If the matching comment is found in {@code drafts}, then it's removed from drafts and the
+   * comment is kept in the output. This assumes that the comment in the input is the newer version
+   * of the previously existing draft.
    *
    * @param inputComments new comments provided as {@link CommentInput} entries in the API.
    * @param existingComments existing published comments in the database.
@@ -464,6 +467,7 @@
           comment.writtenOn = Timestamp.from(ctx.getWhen());
           comment.side = inputComment.side();
           comment.message = inputComment.message;
+          comment.unresolved = inputComment.unresolved;
         }
 
         commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 812711a..d05cbf6 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -155,6 +156,7 @@
       throws BadRequestException, AuthException, PermissionBackendException {
     List<List<ChangeInfo>> out;
     try {
+      applyPermissionBackendFilter();
       out = query();
     } catch (QueryRequiresAuthException e) {
       throw new AuthException("Must be signed-in to use this operator", e);
@@ -165,6 +167,22 @@
     return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
+  private void applyPermissionBackendFilter() {
+    String queryFilter = permissionBackend.currentUser().filterQueryChanges();
+    if (Strings.isNullOrEmpty(queryFilter)) {
+      return;
+    }
+
+    if (queries == null || queries.isEmpty()) {
+      addQuery(queryFilter);
+      return;
+    }
+
+    for (int i = 0; i < queries.size(); i++) {
+      queries.set(i, queries.get(i) + " " + queryFilter);
+    }
+  }
+
   private List<List<ChangeInfo>> query()
       throws BadRequestException, QueryParseException, PermissionBackendException {
     ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
diff --git a/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java b/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java
new file mode 100644
index 0000000..ca366f4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class AccountDeactivation implements RestModifyView<ConfigResource, Input> {
+  private final AccountDeactivator deactivator;
+  private final WorkQueue workQueue;
+
+  @Inject
+  AccountDeactivation(WorkQueue workQueue, AccountDeactivator deactivator) {
+    this.deactivator = deactivator;
+    this.workQueue = workQueue;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource rsrc, Input unusedInput) {
+    if (taskAlreadyScheduled()) {
+      return Response.ok("Account deactivator already in queue.");
+    }
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> deactivator.run());
+    return Response.accepted("Account deactivator task added to work queue.");
+  }
+
+  private boolean taskAlreadyScheduled() {
+    for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+      if (task.toString().contains(deactivator.getClass().getName())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CleanupChanges.java b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
new file mode 100644
index 0000000..9ae3637
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.restapi.config.CleanupChanges.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class CleanupChanges implements RestModifyView<ConfigResource, Input> {
+  private final ChangeCleanupRunner.Factory runnerFactory;
+  private final WorkQueue workQueue;
+
+  public static class Input {
+    String after;
+    boolean ifMergeable;
+    String message;
+  }
+
+  @Inject
+  CleanupChanges(WorkQueue workQueue, ChangeCleanupRunner.Factory runnerFactory) {
+    this.runnerFactory = runnerFactory;
+    this.workQueue = workQueue;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource rsrc, Input input) throws BadRequestException {
+    if (taskAlreadyScheduled()) {
+      return Response.ok("Change cleaner already in queue.");
+    }
+    if (input.after == null) {
+      throw new BadRequestException("`after` must be specified.");
+    }
+    ChangeCleanupRunner runner =
+        runnerFactory.create(
+            ConfigUtil.getTimeUnit(input.after, 0, TimeUnit.MILLISECONDS),
+            input.ifMergeable,
+            input.message);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> runner.run());
+    return Response.accepted("Change cleaner task added to work queue.");
+  }
+
+  private boolean taskAlreadyScheduled() {
+    for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+      if (task.toString().contains(ChangeCleanupRunner.class.getName())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index a17305f..2f21da6 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -38,6 +38,7 @@
 
     child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
     post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    post(CONFIG_KIND, "deactivate.stale.accounts").to(AccountDeactivation.class);
     put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
 
     child(CONFIG_KIND, "experiments").to(ExperimentsCollection.class);
@@ -53,6 +54,7 @@
     put(CONFIG_KIND, "preferences.edit").to(SetEditPreferences.class);
     post(CONFIG_KIND, "reload").to(ReloadConfig.class);
     post(CONFIG_KIND, "snapshot.indexes").to(SnapshotIndexes.class);
+    post(CONFIG_KIND, "cleanup.changes").to(CleanupChanges.class);
 
     child(CONFIG_KIND, "tasks").to(TasksCollection.class);
     delete(TASK_KIND).to(DeleteTask.class);
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 77af0f3..c76f0a4 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -77,6 +77,7 @@
     int tasksTotal = pending.size();
     int tasksStopping = 0;
     int tasksRunning = 0;
+    int tasksParked = 0;
     int tasksStarting = 0;
     int tasksReady = 0;
     int tasksSleeping = 0;
@@ -88,6 +89,9 @@
         case RUNNING:
           tasksRunning++;
           break;
+        case PARKED:
+          tasksParked++;
+          break;
         case STARTING:
           tasksStarting++;
           break;
@@ -108,6 +112,7 @@
     taskSummary.total = toInteger(tasksTotal);
     taskSummary.stopping = toInteger(tasksStopping);
     taskSummary.running = toInteger(tasksRunning);
+    taskSummary.parked = toInteger(tasksParked);
     taskSummary.starting = toInteger(tasksStarting);
     taskSummary.ready = toInteger(tasksReady);
     taskSummary.sleeping = toInteger(tasksSleeping);
@@ -245,6 +250,7 @@
     public Integer total;
     public Integer stopping;
     public Integer running;
+    public Integer parked;
     public Integer starting;
     public Integer ready;
     public Integer sleeping;
diff --git a/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
new file mode 100644
index 0000000..b3ad599
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.AbstractBatchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Base class for a rest API batch update. */
+public abstract class AbstractPostCollection<
+        TId,
+        TResource extends RestResource,
+        TItemInput,
+        TBatchInput extends AbstractBatchInput<TItemInput>>
+    implements RestCollectionModifyView<ProjectResource, TResource, TBatchInput> {
+  private final Provider<CurrentUser> user;
+  private final RepoMetaDataUpdater updater;
+
+  public AbstractPostCollection(RepoMetaDataUpdater updater, Provider<CurrentUser> user) {
+    this.user = user;
+    this.updater = updater;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource rsrc, TBatchInput input)
+      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+          ConfigInvalidException, BadRequestException, ResourceConflictException,
+          MethodNotAllowedException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (input == null) {
+      return Response.ok("");
+    }
+
+    try (var configUpdater =
+        updater.configUpdater(rsrc.getNameKey(), input.commitMessage, defaultCommitMessage())) {
+      ProjectConfig config = configUpdater.getConfig();
+      if (updateProjectConfig(config, input)) {
+        configUpdater.commitConfigUpdate();
+      }
+    }
+    return Response.ok("");
+  }
+
+  public boolean updateProjectConfig(ProjectConfig config, AbstractBatchInput<TItemInput> input)
+      throws UnprocessableEntityException, ResourceConflictException, BadRequestException {
+    boolean configChanged = false;
+    if (input.delete != null && !input.delete.isEmpty()) {
+      for (String name : input.delete) {
+        if (Strings.isNullOrEmpty(name)) {
+          throw new BadRequestException("The delete property contains null or empty name");
+        }
+        deleteItem(config, name.trim());
+      }
+      configChanged = true;
+    }
+    if (input.create != null && !input.create.isEmpty()) {
+      for (TItemInput labelInput : input.create) {
+        if (labelInput == null) {
+          throw new BadRequestException("The create property contains a null item");
+        }
+        createItem(config, labelInput);
+      }
+      configChanged = true;
+    }
+    if (input.update != null && !input.update.isEmpty()) {
+      for (var e : input.update.entrySet()) {
+        if (e.getKey() == null) {
+          throw new BadRequestException("The update property contains a null key");
+        }
+        if (e.getValue() == null) {
+          throw new BadRequestException("The update property contains a null value");
+        }
+        configChanged |= updateItem(config, e.getKey().trim(), e.getValue());
+      }
+    }
+    return configChanged;
+  }
+
+  /** Provides default commit message when user doesn't specify one in the input. */
+  public abstract String defaultCommitMessage();
+
+  protected abstract boolean updateItem(ProjectConfig config, String name, TItemInput resource)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+
+  protected abstract void createItem(ProjectConfig config, TItemInput resource)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+
+  protected abstract void deleteItem(ProjectConfig config, String name)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 3e8002b..3a50275 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
@@ -27,9 +25,10 @@
 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.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,23 +38,18 @@
 @Singleton
 public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
   private final SetAccessUtil setAccess;
-  private final ChangeJson.Factory jsonFactory;
   private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  CreateAccessChange(
-      SetAccessUtil accessUtil,
-      ChangeJson.Factory jsonFactory,
-      RepoMetaDataUpdater repoMetaDataUpdater) {
+  CreateAccessChange(SetAccessUtil accessUtil, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.setAccess = accessUtil;
-    this.jsonFactory = jsonFactory;
     this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws PermissionBackendException, IOException, ConfigInvalidException, InvalidNameException,
-          UpdateException, RestApiException {
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
     ImmutableList<AccessSection> removals =
         setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
@@ -63,28 +57,23 @@
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
-    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Review access change";
-    try {
-      Change change =
-          repoMetaDataUpdater.updateAndCreateChangeForReview(
-              rsrc.getNameKey(),
-              rsrc.getUser(),
-              message,
-              config -> {
-                setAccess.validateChanges(config, removals, additions);
-                setAccess.applyChanges(config, removals, additions);
-                try {
-                  setAccess.setParentName(
-                      rsrc.getUser().asIdentifiedUser(),
-                      config,
-                      rsrc.getNameKey(),
-                      newParentProjectName,
-                      false);
-                } catch (AuthException e) {
-                  throw new IllegalStateException(e);
-                }
-              });
-      return Response.created(jsonFactory.noOptions().format(change));
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.message, "Review access change")) {
+      ProjectConfig config = creator.getConfig();
+      setAccess.validateChanges(config, removals, additions);
+      setAccess.applyChanges(config, removals, additions);
+      try {
+        setAccess.setParentName(
+            rsrc.getUser().asIdentifiedUser(),
+            config,
+            rsrc.getNameKey(),
+            newParentProjectName,
+            false);
+      } catch (AuthException e) {
+        throw new IllegalStateException(e);
+      }
+      return creator.createChange();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index a233834..6bec417 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -23,23 +23,18 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
@@ -49,43 +44,22 @@
 @Singleton
 public class CreateLabel
     implements RestCollectionCreateView<ProjectResource, LabelResource, LabelDefinitionInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public CreateLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      ApprovalQueryBuilder approvalQueryBuilder) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.approvalQueryBuilder = approvalQueryBuilder;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<LabelDefinitionInfo> apply(
       ProjectResource rsrc, IdString id, LabelDefinitionInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
-          PermissionBackendException, IOException, ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          PermissionBackendException, IOException, ConfigInvalidException,
+          MethodNotAllowedException {
     if (input == null) {
       input = new LabelDefinitionInput();
     }
@@ -93,22 +67,10 @@
     if (input.name != null && !input.name.equals(id.get())) {
       throw new BadRequestException("name in input must match name in URL");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      LabelType labelType = createLabel(config, id.get(), input);
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Update label");
-      }
-
-      config.commit(md);
-
-      projectCache.evictAndReindex(rsrc.getProjectState().getProject());
-
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(rsrc.getNameKey(), input.commitMessage, "Update label")) {
+      LabelType labelType = createLabel(configUpdater.getConfig(), id.get(), input);
+      configUpdater.commitConfigUpdate();
       return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
index a46211c..3fa4905 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -23,15 +23,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
@@ -39,7 +35,6 @@
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -50,42 +45,43 @@
 public class CreateSubmitRequirement
     implements RestCollectionCreateView<
         ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+  private final RepoMetaDataUpdater updater;
 
   @Inject
   public CreateSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+      RepoMetaDataUpdater updater) {
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+    this.updater = updater;
   }
 
   @Override
   public Response<SubmitRequirementInfo> apply(
       ProjectResource rsrc, IdString id, SubmitRequirementInput input)
-      throws AuthException, BadRequestException, IOException, PermissionBackendException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+      throws AuthException, BadRequestException, IOException, PermissionBackendException,
+          MethodNotAllowedException {
+    String defaultMessage = String.format("Create Submit Requirement %s", id.get());
+    try (var configUpdater =
+        updater.configUpdater(
+            rsrc.getNameKey(),
+            /** message= */
+            null,
+            defaultMessage)) {
+      ProjectConfig config = configUpdater.getConfig();
+      SubmitRequirement submitRequirement = updateConfig(config, id, input);
+
+      configUpdater.commitConfigUpdate();
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
     }
+  }
 
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+  SubmitRequirement updateConfig(ProjectConfig config, IdString id, SubmitRequirementInput input)
+      throws ResourceConflictException, BadRequestException {
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -93,23 +89,7 @@
     if (input.name != null && !input.name.equals(id.get())) {
       throw new BadRequestException("name in input must match name in URL");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
-
-      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
-      config.commit(md);
-
-      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
-
-      return Response.created(SubmitRequirementJson.format(submitRequirement));
-    } catch (ConfigInvalidException e) {
-      throw new IOException("Failed to read project config", e);
-    } catch (ResourceConflictException e) {
-      throw new BadRequestException("Failed to create submit requirement", e);
-    }
+    return createSubmitRequirement(config, id.get(), input);
   }
 
   public SubmitRequirement createSubmitRequirement(
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 8a1927a..1c3ed5d 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -14,84 +14,50 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteLabel implements RestModifyView<LabelResource, InputWithCommitMessage> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  public DeleteLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+  public DeleteLabel(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<?> apply(LabelResource rsrc, InputWithCommitMessage input)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
-          ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          ConfigInvalidException, BadRequestException, MethodNotAllowedException {
     if (input == null) {
       input = new InputWithCommitMessage();
     }
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), input.commitMessage, "Delete label")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (!deleteLabel(config, rsrc.getLabelType().getName())) {
         throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
       }
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Delete label");
-      }
-
-      config.commit(md);
+      configUpdater.commitConfigUpdate();
     }
 
-    projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
-
     return Response.none();
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
index 1be4a5f..64e2399 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -15,57 +15,30 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  public DeleteSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+  public DeleteSubmitRequirement(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), null, "Delete submit requirement")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
         // This code is unreachable because the exception is thrown when rsrc was parsed
@@ -75,12 +48,9 @@
                 IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
       }
 
-      md.setMessage("Delete submit requirement");
-      config.commit(md);
+      configUpdater.commitConfigUpdate();
     }
 
-    projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
-
     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 e1a3c0c..d83605e 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_TAG_REF;
+import static com.google.gerrit.server.permissions.ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.READ;
 import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
@@ -269,6 +270,8 @@
     info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
     info.canAddTags = toBoolean(perm.testOrFalse(CREATE_TAG_REF));
     info.configVisible = canReadConfig || canWriteConfig;
+    info.requireChangeForConfigUpdate =
+        toBoolean(!perm.testOrFalse(UPDATE_CONFIG_WITHOUT_CREATING_CHANGE));
 
     info.groups =
         groups.entrySet().stream()
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 24bbb24..2132153 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -105,10 +105,7 @@
     this.matchRegex = matchRegex;
   }
 
-  @Option(
-      name = "--sort-by",
-      aliases = {"-sortby"},
-      usage = "sort the tags")
+  @Option(name = "--sort-by", usage = "sort the tags")
   private void setSortBy(ListTagSortOption sortBy) {
     this.sortBy = sortBy;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 3616f4b..7f502ff 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -14,138 +14,76 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
-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.Response;
-import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectResource;
 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.errors.ConfigInvalidException;
 
 /** REST endpoint that allows to add, update and delete label definitions in a batch. */
 @Singleton
 public class PostLabels
-    implements RestCollectionModifyView<ProjectResource, LabelResource, BatchLabelInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
+    extends AbstractPostCollection<String, LabelResource, LabelDefinitionInput, BatchLabelInput> {
   private final DeleteLabel deleteLabel;
   private final CreateLabel createLabel;
   private final SetLabel setLabel;
-  private final ProjectCache projectCache;
 
   @Inject
   public PostLabels(
       Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
       DeleteLabel deleteLabel,
       CreateLabel createLabel,
       SetLabel setLabel,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
+      RepoMetaDataUpdater updater) {
+    super(updater, user);
     this.deleteLabel = deleteLabel;
     this.createLabel = createLabel;
     this.setLabel = setLabel;
-    this.projectCache = projectCache;
   }
 
   @Override
-  public Response<?> apply(ProjectResource rsrc, BatchLabelInput input)
-      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
-          ConfigInvalidException, BadRequestException, ResourceConflictException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+  public String defaultCommitMessage() {
+    return "Update labels";
+  }
+
+  @Override
+  protected boolean updateItem(ProjectConfig config, String name, LabelDefinitionInput resource)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException {
+    LabelType labelType = config.getLabelSections().get(name);
+    if (labelType == null) {
+      throw new UnprocessableEntityException(String.format("label %s not found", name));
+    }
+    if (resource.commitMessage != null) {
+      throw new BadRequestException("commit message on label definition input not supported");
     }
 
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
+    return setLabel.updateLabel(config, labelType, resource);
+  }
 
-    if (input == null) {
-      input = new BatchLabelInput();
+  @Override
+  protected void createItem(ProjectConfig config, LabelDefinitionInput labelInput)
+      throws BadRequestException, ResourceConflictException {
+    if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
+      throw new BadRequestException("label name is required for new label");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      boolean dirty = false;
-
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      if (input.delete != null && !input.delete.isEmpty()) {
-        for (String labelName : input.delete) {
-          if (!deleteLabel.deleteLabel(config, labelName.trim())) {
-            throw new UnprocessableEntityException(String.format("label %s not found", labelName));
-          }
-        }
-        dirty = true;
-      }
-
-      if (input.create != null && !input.create.isEmpty()) {
-        for (LabelDefinitionInput labelInput : input.create) {
-          if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
-            throw new BadRequestException("label name is required for new label");
-          }
-          if (labelInput.commitMessage != null) {
-            throw new BadRequestException("commit message on label definition input not supported");
-          }
-          @SuppressWarnings("unused")
-          var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
-        }
-        dirty = true;
-      }
-
-      if (input.update != null && !input.update.isEmpty()) {
-        for (Map.Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
-          LabelType labelType = config.getLabelSections().get(e.getKey().trim());
-          if (labelType == null) {
-            throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
-          }
-          if (e.getValue().commitMessage != null) {
-            throw new BadRequestException("commit message on label definition input not supported");
-          }
-
-          if (setLabel.updateLabel(config, labelType, e.getValue())) {
-            dirty = true;
-          }
-        }
-      }
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Update labels");
-      }
-
-      if (dirty) {
-        config.commit(md);
-        projectCache.evictAndReindex(rsrc.getProjectState().getProject());
-      }
+    if (labelInput.commitMessage != null) {
+      throw new BadRequestException("commit message on label definition input not supported");
     }
+    @SuppressWarnings("unused")
+    var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+  }
 
-    return Response.ok("");
+  @Override
+  protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+    if (!deleteLabel.deleteLabel(config, name)) {
+      throw new UnprocessableEntityException(String.format("label %s not found", name));
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
new file mode 100644
index 0000000..7c0936f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostLabelsReview implements RestModifyView<ProjectResource, BatchLabelInput> {
+
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PostLabels postLabels;
+
+  @Inject
+  PostLabelsReview(RepoMetaDataUpdater repoMetaDataUpdater, PostLabels postLabels) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.postLabels = postLabels;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, BatchLabelInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review labels change")) {
+      ProjectConfig config = creator.getConfig();
+      var unused = postLabels.updateProjectConfig(config, input);
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
new file mode 100644
index 0000000..71080a5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Provider;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+@Singleton
+public class PostSubmitRequirements
+    extends AbstractPostCollection<
+        IdString, SubmitRequirementResource, SubmitRequirementInput, BatchSubmitRequirementInput> {
+  CreateSubmitRequirement createSubmitRequirement;
+  DeleteSubmitRequirement deleteSubmitRequirement;
+  UpdateSubmitRequirement updateSubmitRequirement;
+
+  @Inject
+  public PostSubmitRequirements(
+      RepoMetaDataUpdater updater,
+      Provider<CurrentUser> user,
+      CreateSubmitRequirement createSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement) {
+    super(updater, user);
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+  }
+
+  @Override
+  public String defaultCommitMessage() {
+    return "Update Submit Requirements";
+  }
+
+  @Override
+  protected boolean updateItem(ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, UnprocessableEntityException {
+    // The name and input.name can be different - the item should be renamed.
+    if (config.getSubmitRequirementSections().remove(name) == null) {
+      throw new UnprocessableEntityException(
+          String.format("Submit requirement %s not found", name));
+    }
+    var unused = updateSubmitRequirement.updateSubmitRequirement(config, input.name, input);
+    return true;
+  }
+
+  @Override
+  protected void createItem(ProjectConfig config, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    var unused = createSubmitRequirement.createSubmitRequirement(config, input.name, input);
+  }
+
+  @Override
+  protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+    if (!deleteSubmitRequirement.deleteSubmitRequirement(config, name)) {
+      throw new UnprocessableEntityException(
+          String.format("Submit requirement %s not found", name));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
new file mode 100644
index 0000000..82761e7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostSubmitRequirementsReview
+    implements RestModifyView<ProjectResource, BatchSubmitRequirementInput> {
+
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PostSubmitRequirements postSubmitRequirements;
+
+  @Inject
+  PostSubmitRequirementsReview(
+      RepoMetaDataUpdater repoMetaDataUpdater, PostSubmitRequirements postSubmitRequirements) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.postSubmitRequirements = postSubmitRequirements;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, BatchSubmitRequirementInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review submit requirements change")) {
+      ProjectConfig config = creator.getConfig();
+      var unused = postSubmitRequirements.updateProjectConfig(config, input);
+      // If config isn't updated, the createChange throws BadRequestException. We don't need
+      // to explicitly check the updateProjectConfig result here.
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index a7e7894..5c8bf3d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -84,6 +84,7 @@
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
+    put(PROJECT_KIND, "config:review").to(PutConfigReview.class);
 
     post(PROJECT_KIND, "create.change").to(CreateChange.class);
 
@@ -102,10 +103,11 @@
 
     child(PROJECT_KIND, "labels").to(LabelsCollection.class);
     create(LABEL_KIND).to(CreateLabel.class);
-    postOnCollection(LABEL_KIND).to(PostLabels.class);
     get(LABEL_KIND).to(GetLabel.class);
     put(LABEL_KIND).to(SetLabel.class);
     delete(LABEL_KIND).to(DeleteLabel.class);
+    postOnCollection(LABEL_KIND).to(PostLabels.class);
+    post(PROJECT_KIND, "labels:review").to(PostLabelsReview.class);
 
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
@@ -115,6 +117,8 @@
     put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
     get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
     delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+    postOnCollection(SUBMIT_REQUIREMENT_KIND).to(PostSubmitRequirements.class);
+    post(PROJECT_KIND, "submit_requirements:review").to(PostSubmitRequirementsReview.class);
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     create(TAG_KIND).to(CreateTag.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index d5f61ce..76b536a 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -34,7 +34,9 @@
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -47,15 +49,14 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -65,7 +66,6 @@
 import java.util.Map;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -76,8 +76,6 @@
       Pattern.compile("^[a-zA-Z0-9]+[a-zA-Z0-9-]*$");
 
   private final boolean serverEnableSignedPush;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
@@ -88,11 +86,11 @@
   private final PermissionBackend permissionBackend;
   private final ProjectConfig.Factory projectConfigFactory;
 
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+
   @Inject
   PutConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
@@ -101,10 +99,9 @@
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user,
       PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
+      ProjectConfig.Factory projectConfigFactory,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
@@ -114,6 +111,7 @@
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -127,72 +125,74 @@
   }
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, AuthException, MethodNotAllowedException {
     Project.NameKey projectName = projectState.getNameKey();
     if (input == null) {
       throw new BadRequestException("config is required");
     }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.updateProject(
-          p -> {
-            p.setDescription(Strings.emptyToNull(input.description));
-            for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
-              InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
-              if (val != null) {
-                p.setBooleanConfig(cfg, val);
-              }
-            }
-            if (input.maxObjectSizeLimit != null) {
-              p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
-            }
-            if (input.submitType != null) {
-              p.setSubmitType(input.submitType);
-            }
-            if (input.state != null) {
-              p.setState(input.state);
-            }
-          });
-
-      if (input.pluginConfigValues != null) {
-        setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
-      }
-
-      if (input.commentLinks != null) {
-        updateCommentLinks(projectConfig, input.commentLinks);
-      }
-
-      md.setMessage("Modified project settings\n");
-      try {
-        projectConfig.commit(md);
-        projectCache.evictAndReindex(projectConfig.getProject());
-        md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
-      } catch (IOException e) {
-        if (e.getCause() instanceof ConfigInvalidException) {
-          throw new ResourceConflictException(
-              "Cannot update " + projectName + ": " + e.getCause().getMessage());
-        }
-        logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
-        throw new ResourceConflictException("Cannot update " + projectName);
-      }
-
-      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
+    try (ConfigUpdater updater =
+        repoMetaDataUpdater.configUpdater(
+            projectName, input.commitMessage, "Modified project settings")) {
+      updateConfig(projectState, updater.getConfig(), input);
+      updater.commitConfigUpdate();
+      updater
+          .getRepository()
+          .setGitwebDescription(updater.getConfig().getProject().getDescription());
+      ProjectState newProjectState =
+          projectStateFactory.create(
+              projectConfigFactory.read(updater.getRepository(), projectName).getCacheable());
       return ConfigInfoCreator.constructInfo(
           serverEnableSignedPush,
-          state,
+          newProjectState,
           user.get(),
           pluginConfigEntries,
           cfgFactory,
           allProjects,
           uiActions,
           views);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(projectName.get(), notFound);
+
+    } catch (IOException e) {
+      if (e.getCause() instanceof ConfigInvalidException) {
+        throw new ResourceConflictException(
+            "Cannot update " + projectName + ": " + e.getCause().getMessage());
+      }
+      logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
+      throw new ResourceConflictException("Cannot update " + projectName);
     } catch (ConfigInvalidException err) {
       throw new ResourceConflictException("Cannot read project " + projectName, err);
-    } catch (IOException err) {
-      throw new ResourceConflictException("Cannot update project " + projectName, err);
+    }
+  }
+
+  public void updateConfig(
+      ProjectState projectState, ProjectConfig projectConfig, ConfigInput input)
+      throws BadRequestException {
+    projectConfig.updateProject(
+        p -> {
+          p.setDescription(Strings.emptyToNull(input.description));
+          for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+            InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+            if (val != null) {
+              p.setBooleanConfig(cfg, val);
+            }
+          }
+          if (input.maxObjectSizeLimit != null) {
+            p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+          }
+          if (input.submitType != null) {
+            p.setSubmitType(input.submitType);
+          }
+          if (input.state != null) {
+            p.setState(input.state);
+          }
+        });
+
+    if (input.pluginConfigValues != null) {
+      setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
+    }
+
+    if (input.commentLinks != null) {
+      updateCommentLinks(projectConfig, input.commentLinks);
     }
   }
 
@@ -302,7 +302,7 @@
     }
   }
 
-  private void updateCommentLinks(
+  private static void updateCommentLinks(
       ProjectConfig projectConfig, Map<String, CommentLinkInput> input) {
     for (Map.Entry<String, CommentLinkInput> e : input.entrySet()) {
       String name = e.getKey();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
new file mode 100644
index 0000000..5c51003
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutConfigReview implements RestModifyView<ProjectResource, ConfigInput> {
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PutConfig putConfig;
+
+  @Inject
+  PutConfigReview(RepoMetaDataUpdater repoMetaDataUpdater, PutConfig putConfig) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.putConfig = putConfig;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ConfigInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review config change")) {
+      putConfig.updateConfig(rsrc.getProjectState(), creator.getConfig(), input);
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index ec42035..20f439c 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -14,24 +14,19 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -39,53 +34,30 @@
 
 @Singleton
 public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
-  private final ProjectCache cache;
-  private final Provider<MetaDataUpdate.Server> updateFactory;
-  private final PermissionBackend permissionBackend;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  PutDescription(
-      ProjectCache cache,
-      Provider<MetaDataUpdate.Server> updateFactory,
-      PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
-    this.permissionBackend = permissionBackend;
-    this.projectConfigFactory = projectConfigFactory;
+  PutDescription(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<String> apply(ProjectResource resource, DescriptionInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException,
-          PermissionBackendException {
+          PermissionBackendException, BadRequestException, MethodNotAllowedException {
     if (input == null) {
       input = new DescriptionInput(); // Delete would set description to null.
     }
 
-    IdentifiedUser user = resource.getUser().asIdentifiedUser();
-    permissionBackend
-        .user(user)
-        .project(resource.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
-    try (MetaDataUpdate md = updateFactory.get().create(resource.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            resource.getNameKey(), input.commitMessage, "Update description")) {
+      ProjectConfig config = configUpdater.getConfig();
       String desc = input.description;
       config.updateProject(p -> p.setDescription(Strings.emptyToNull(desc)));
 
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage), "Update description\n");
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(resource.getProjectState().getProject());
-      md.getRepository().setGitwebDescription(config.getProject().getDescription());
+      configUpdater.commitConfigUpdate();
+      configUpdater.getRepository().setGitwebDescription(config.getProject().getDescription());
 
       return Strings.isNullOrEmpty(config.getProject().getDescription())
           ? Response.none()
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
index c45a009..9fb536c 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -15,24 +15,29 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.MustBeClosed;
 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.exceptions.InvalidNameException;
+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.MethodNotAllowedException;
 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.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.MetaDataUpdate.User;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -49,18 +54,20 @@
 import javax.inject.Inject;
 import javax.inject.Provider;
 import javax.inject.Singleton;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 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.RevWalk;
 
 /** Updates repo refs/meta/config content. */
 @Singleton
 public class RepoMetaDataUpdater {
-  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final Provider<User> metaDataUpdateFactory;
+  private final Provider<CurrentUser> user;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
   private final ChangeInserter.Factory changeInserterFactory;
@@ -69,38 +76,61 @@
   private final BatchUpdate.Factory updateFactory;
 
   private final PermissionBackend permissionBackend;
+  private final ChangeJson.Factory jsonFactory;
 
   @Inject
   RepoMetaDataUpdater(
-      CreateGroupPermissionSyncer createGroupPermissionSyncer,
       Provider<User> metaDataUpdateFactory,
+      Provider<CurrentUser> user,
       ProjectConfig.Factory projectConfigFactory,
       ProjectCache projectCache,
       ChangeInserter.Factory changeInserterFactory,
       Sequences seq,
       BatchUpdate.Factory updateFactory,
-      PermissionBackend permissionBackend) {
-    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+      PermissionBackend permissionBackend,
+      ChangeJson.Factory jsonFactory) {
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.user = user;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
     this.changeInserterFactory = changeInserterFactory;
     this.seq = seq;
     this.updateFactory = updateFactory;
     this.permissionBackend = permissionBackend;
+    this.jsonFactory = jsonFactory;
   }
 
-  public Change updateAndCreateChangeForReview(
-      Project.NameKey projectName,
-      CurrentUser user,
-      String message,
-      ProjectConfigUpdater projectConfigUpdater)
-      throws ConfigInvalidException, IOException, RestApiException, UpdateException,
-          InvalidNameException, PermissionBackendException {
-    checkArgument(!message.isBlank(), "The message must not be empty");
-    message = validateMessage(message);
-
-    PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
+  /**
+   * Returns a creator for creating project config changes.
+   *
+   * <p>The method checks that user has required permissions.
+   *
+   * <p>Usage:
+   *
+   * <pre>{@code
+   * try(var changeCreator =
+   *  repoMetaDataUpdater.configChangeCreator(projectName, message, defaultMessage)) {
+   *    ProjectConfig config = changeCreator.getConfig();
+   *    // ... update project config
+   *    // Create change - if the createChange method is not called, all updates are ignored and no
+   *    // change is created.
+   *    Response<ChangeInfo> result = changeCreator.createChange();
+   *  }
+   * }</pre>
+   *
+   * @param projectName the name of the project whose config should be updated
+   * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+   *     empty) - the {@code defaultMessage} is used.
+   * @param defaultMessage the default commit message if the user doesn't provide one.
+   */
+  @MustBeClosed
+  public ConfigChangeCreator configChangeCreator(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws PermissionBackendException, AuthException, ResourceConflictException, IOException,
+          ConfigInvalidException {
+    message = validateMessage(message, defaultMessage);
+    PermissionBackend.ForProject forProject =
+        permissionBackend.user(user.get()).project(projectName);
     if (!check(forProject, ProjectPermission.READ_CONFIG)) {
       throw new AuthException(RefNames.REFS_CONFIG + " not visible");
     }
@@ -112,15 +142,166 @@
       }
     }
     projectCache.get(projectName).orElseThrow(illegalState(projectName)).checkStatePermitsWrite();
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig config = projectConfigFactory.read(md);
-      ObjectId oldCommit = config.getRevision();
-      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
-
-      projectConfigUpdater.update(config);
-      md.setMessage(message);
+    // The MetaDataUpdate instance gets closed in the ConfigChangeCreator.close() method.
+    MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+    try {
       md.setInsertChangeId(true);
+      md.setMessage(message);
+      ProjectConfig config = projectConfigFactory.read(md);
+      return new ConfigChangeCreator(md, projectName, user.get(), config);
+    } catch (Throwable t) {
+      try (md) {
+        throw t;
+      }
+    }
+  }
+
+  /**
+   * Returns an updater for updating project config without review.
+   *
+   * <p>The method checks that user has required permissions and that user can update config without
+   * review.
+   *
+   * <p>When the update is saved (using the {@link ConfigUpdater#commitConfigUpdate} method), the
+   * project cache is updated automatically.
+   *
+   * <p>Usage:
+   *
+   * <pre>{@code
+   * try(var configUpdater =
+   *  repoMetaDataUpdater.configUpdater(projectName, message, defaultMessage)) {
+   *    ProjectConfig config = changeCreator.getConfig();
+   *    // ... update project config
+   *    // Save updated config - if the commitConfigUpdate method is not called, all updates are ignored.
+   *    configUpdater.commitConfigUpdate();
+   *  }
+   * }</pre>
+   *
+   * @param projectName the name of the project whose config should be updated
+   * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+   *     empty) - the {@code defaultMessage} is used.
+   * @param defaultMessage the default commit message if the user doesn't provide one.
+   */
+  @MustBeClosed
+  public ConfigUpdater configUpdater(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws AuthException, PermissionBackendException, ConfigInvalidException, IOException,
+          BadRequestException, MethodNotAllowedException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    permissionBackend.user(user.get()).project(projectName).check(ProjectPermission.WRITE_CONFIG);
+    return configUpdaterWithoutPermissionsCheck(projectName, message, defaultMessage);
+  }
+
+  /**
+   * Returns an updater for updating project config without review and skips some permissions
+   * checks.
+   *
+   * <p>The method only checks that user can update config without review and doesn't do any other
+   * permissions checks. It should be used only when standard permissions checks from {@link
+   * #configUpdater} can't be used.
+   *
+   * <p>See {@link #configUpdater} for details.
+   */
+  @MustBeClosed
+  public ConfigUpdater configUpdaterWithoutPermissionsCheck(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws IOException, ConfigInvalidException, MethodNotAllowedException,
+          PermissionBackendException {
+    if (!permissionBackend
+        .currentUser()
+        .project(projectName)
+        .test(ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE)) {
+      throw new MethodNotAllowedException(
+          "Updating project config without review is disabled. Please create a change and send it "
+              + "for review. Some rest API methods have alternatives for creating required changes "
+              + "automatically - please check gerrit documentation.");
+    }
+    message = validateMessage(message, defaultMessage);
+    // The MetaDataUpdate instance gets closed in the ConfigUpdater.close() method.
+    MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+    try {
+      ProjectConfig config = projectConfigFactory.read(md);
+      md.setMessage(message);
+      return new ConfigUpdater(md, config);
+    } catch (Throwable t) {
+      try (md) {
+        throw t;
+      }
+    }
+  }
+
+  /**
+   * Updater for a project config without review.
+   *
+   * <p>See {@link #configUpdater} and {@link #configUpdaterWithoutPermissionsCheck} for details and
+   * usages.
+   */
+  public class ConfigUpdater implements AutoCloseable {
+    private final MetaDataUpdate md;
+    private final ProjectConfig config;
+
+    private ConfigUpdater(MetaDataUpdate md, ProjectConfig config) {
+      this.md = md;
+      this.config = config;
+    }
+
+    public ProjectConfig getConfig() {
+      return config;
+    }
+
+    public void commitConfigUpdate() throws IOException {
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+    }
+
+    public Repository getRepository() {
+      return md.getRepository();
+    }
+
+    @Override
+    public void close() {
+      md.close();
+    }
+  }
+
+  /**
+   * Creates a change for a project config update.
+   *
+   * <p>See {@link #createChange} for details and usages.
+   */
+  public class ConfigChangeCreator implements AutoCloseable {
+    private final MetaDataUpdate md;
+    private final String oldCommitSha1;
+    private final Project.NameKey projectName;
+    private final CurrentUser user;
+    private final ProjectConfig config;
+    private boolean changeCreated;
+
+    private ConfigChangeCreator(
+        MetaDataUpdate md, Project.NameKey projectName, CurrentUser user, ProjectConfig config) {
+      this.md = md;
+      this.config = config;
+      this.projectName = projectName;
+      this.user = user;
+      ObjectId oldCommit = config.getRevision();
+      oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
+    }
+
+    @Override
+    public void close() {
+      md.close();
+    }
+
+    public ProjectConfig getConfig() {
+      return config;
+    }
+
+    public Response<ChangeInfo> createChange()
+        throws IOException, UpdateException, RestApiException {
+      checkState(!changeCreated, "Change has been already created");
+      changeCreated = true;
 
       Change.Id changeId = Change.id(seq.nextChangeId());
       try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -140,62 +321,38 @@
           ChangeInserter ins = newInserter(changeId, commit);
           bu.insertChange(ins);
           bu.execute();
-          return ins.getChange();
+          Change change = ins.getChange();
+          return Response.created(jsonFactory.noOptions().format(change));
         }
       }
     }
-  }
 
-  public void updateWithoutReview(
-      Project.NameKey projectName, String message, ProjectConfigUpdater projectConfigUpdater)
-      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
-          ResourceConflictException, InvalidNameException, BadRequestException {
-    updateWithoutReview(
-        projectName, message, /*skipPermissionsCheck=*/ false, projectConfigUpdater);
-  }
-
-  public void updateWithoutReview(
-      Project.NameKey projectName,
-      String message,
-      boolean skipPermissionsCheck,
-      ProjectConfigUpdater projectConfigUpdater)
-      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
-          ResourceConflictException, InvalidNameException, BadRequestException {
-    message = validateMessage(message);
-    if (!skipPermissionsCheck) {
-      permissionBackend.currentUser().project(projectName).check(ProjectPermission.WRITE_CONFIG);
-    }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      projectConfigUpdater.update(config);
-      md.setMessage(message);
-      config.commit(md);
-      projectCache.evictAndReindex(config.getProject());
-      createGroupPermissionSyncer.syncIfNeeded();
+    // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+    @SuppressWarnings("deprecation")
+    private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+      return changeInserterFactory
+          .create(changeId, commit, RefNames.REFS_CONFIG)
+          .setMessage(
+              // Same message as in ReceiveCommits.CreateRequest.
+              ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+          .setValidate(false)
+          .setUpdateRef(false);
     }
   }
 
-  private String validateMessage(String message) {
+  private String validateMessage(@Nullable String message, String defaultMessage) {
+    if (Strings.isNullOrEmpty(message)) {
+      message = defaultMessage;
+    } else {
+      message = message.trim();
+    }
+    checkArgument(!message.isBlank(), "The message must not be empty");
     if (!message.endsWith("\n")) {
       return message + "\n";
     }
     return message;
   }
 
-  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
-  @SuppressWarnings("deprecation")
-  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
-    return changeInserterFactory
-        .create(changeId, commit, RefNames.REFS_CONFIG)
-        .setMessage(
-            // Same message as in ReceiveCommits.CreateRequest.
-            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
-        .setValidate(false)
-        .setUpdateRef(false);
-  }
-
   private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
       throws PermissionBackendException {
     try {
@@ -205,11 +362,4 @@
       return false;
     }
   }
-
-  @FunctionalInterface
-  public interface ProjectConfigUpdater {
-    void update(ProjectConfig config)
-        throws BadRequestException, InvalidNameException, PermissionBackendException,
-            ResourceConflictException, AuthException;
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 75fe280..65851c0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.AccessSection;
@@ -29,12 +28,15 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -49,6 +51,7 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
   private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
 
   @Inject
   private SetAccess(
@@ -57,6 +60,7 @@
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
       RepoMetaDataUpdater repoMetaDataUpdater) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
@@ -64,6 +68,7 @@
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
     this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
   }
 
   @Override
@@ -75,42 +80,40 @@
         accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
         accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
-    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Modify access rules";
-    try {
-      this.repoMetaDataUpdater.updateWithoutReview(
+
+    try (ConfigUpdater updater =
+        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+            rsrc.getNameKey(), input.message, "Modify access rules")) {
+      ProjectConfig config = updater.getConfig();
+      boolean checkedAdmin = false;
+      for (AccessSection section : Iterables.concat(additions, removals)) {
+        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+        if (isGlobalCapabilities) {
+          if (!checkedAdmin) {
+            permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+            checkedAdmin = true;
+          }
+        } else {
+          permissionBackend
+              .currentUser()
+              .project(rsrc.getNameKey())
+              .ref(section.getName())
+              .check(RefPermission.WRITE_CONFIG);
+        }
+      }
+
+      accessUtil.validateChanges(config, removals, additions);
+      accessUtil.applyChanges(config, removals, additions);
+
+      accessUtil.setParentName(
+          identifiedUser.get(),
+          config,
           rsrc.getNameKey(),
-          message,
-          /*skipPermissionsCheck=*/ true,
-          config -> {
-            // Check that the user has the right permissions.
-            boolean checkedAdmin = false;
-            for (AccessSection section : Iterables.concat(additions, removals)) {
-              boolean isGlobalCapabilities =
-                  AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-              if (isGlobalCapabilities) {
-                if (!checkedAdmin) {
-                  permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-                  checkedAdmin = true;
-                }
-              } else {
-                permissionBackend
-                    .currentUser()
-                    .project(rsrc.getNameKey())
-                    .ref(section.getName())
-                    .check(RefPermission.WRITE_CONFIG);
-              }
-            }
+          input.parent == null ? null : Project.nameKey(input.parent),
+          !checkedAdmin);
 
-            accessUtil.validateChanges(config, removals, additions);
-            accessUtil.applyChanges(config, removals, additions);
-
-            accessUtil.setParentName(
-                identifiedUser.get(),
-                config,
-                rsrc.getNameKey(),
-                input.parent == null ? null : Project.nameKey(input.parent),
-                !checkedAdmin);
-          });
+      updater.commitConfigUpdate();
+      createGroupPermissionSyncer.syncIfNeeded();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 853d7df..a46ee32 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
@@ -25,12 +24,8 @@
 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.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -41,30 +36,21 @@
 import org.kohsuke.args4j.Option;
 
 class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
-  private final ProjectCache cache;
-  private final MetaDataUpdate.Server updateFactory;
   private final DashboardsCollection dashboards;
   private final Provider<GetDashboard> get;
-  private final PermissionBackend permissionBackend;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
   boolean inherited;
 
   @Inject
   SetDefaultDashboard(
-      ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
       Provider<GetDashboard> get,
-      PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.dashboards = dashboards;
     this.get = get;
-    this.permissionBackend = permissionBackend;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -75,11 +61,6 @@
     }
     input.id = Strings.emptyToNull(input.id);
 
-    permissionBackend
-        .user(rsrc.getUser())
-        .project(rsrc.getProjectState().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
     DashboardResource target = null;
     if (input.id != null) {
       try {
@@ -93,29 +74,22 @@
         throw new ResourceConflictException(e.getMessage());
       }
     }
+    String defaultMessage =
+        input.id == null
+            ? "Removed default dashboard.\n"
+            : String.format("Changed default dashboard to %s.\n", input.id);
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProjectState().getNameKey(), input.commitMessage, defaultMessage)) {
+      ProjectConfig config = configUpdater.getConfig();
       String id = input.id;
       if (inherited) {
         config.updateProject(p -> p.setDefaultDashboard(id));
       } else {
         config.updateProject(p -> p.setLocalDefaultDashboard(id));
       }
-
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage),
-              input.id == null
-                  ? "Removed default dashboard.\n"
-                  : String.format("Changed default dashboard to %s.\n", input.id));
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(rsrc.getUser().asIdentifiedUser());
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(rsrc.getProjectState().getProject());
+      configUpdater.commitConfigUpdate();
 
       if (target != null) {
         Response<DashboardInfo> response = get.get().apply(target);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index edd165d..0779fdd 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -20,22 +20,17 @@
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -43,63 +38,37 @@
 
 @Singleton
 public class SetLabel implements RestModifyView<LabelResource, LabelDefinitionInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public SetLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      ApprovalQueryBuilder approvalQueryBuilder) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.approvalQueryBuilder = approvalQueryBuilder;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<LabelDefinitionInfo> apply(LabelResource rsrc, LabelDefinitionInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
-          PermissionBackendException, IOException, ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          PermissionBackendException, IOException, ConfigInvalidException,
+          MethodNotAllowedException {
     if (input == null) {
       input = new LabelDefinitionInput();
     }
 
     LabelType labelType = rsrc.getLabelType();
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), input.commitMessage, "Update label")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (updateLabel(config, labelType, input)) {
-        if (input.commitMessage != null) {
-          md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-        } else {
-          md.setMessage("Update label");
-        }
         String newName = Strings.nullToEmpty(input.name).trim();
         labelType =
             config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
-
-        config.commit(md);
-        projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
+        configUpdater.commitConfigUpdate();
       }
     }
     return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index ef31dc5..fedd240 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -38,7 +39,6 @@
 import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -48,7 +48,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -60,61 +59,52 @@
     implements RestModifyView<ProjectResource, ParentInput>, GerritConfigListener {
   private final ProjectCache cache;
   private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.Server> updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
   private volatile boolean allowProjectOwnersToChangeParent;
 
   @Inject
   SetParent(
       ProjectCache cache,
       PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.Server> updateFactory,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      ProjectConfig.Factory projectConfigFactory,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
-    this.projectConfigFactory = projectConfigFactory;
     this.allowProjectOwnersToChangeParent =
         config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<String> apply(ProjectResource rsrc, ParentInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException,
-          BadRequestException {
+          BadRequestException, MethodNotAllowedException {
     return Response.ok(apply(rsrc, input, true));
   }
 
   public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException,
-          BadRequestException {
+          BadRequestException, MethodNotAllowedException {
     IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
-    try (MetaDataUpdate md = updateFactory.get().create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+            rsrc.getNameKey(),
+            input.commitMessage,
+            String.format("Changed parent to %s.\n", parentName))) {
+      ProjectConfig config = configUpdater.getConfig();
       config.updateProject(p -> p.setParent(parentName));
-
-      String msg = Strings.emptyToNull(input.commitMessage);
-      if (msg == null) {
-        msg = String.format("Changed parent to %s.\n", parentName);
-      } else if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(rsrc.getProjectState().getProject());
+      configUpdater.commitConfigUpdate();
 
       Project.NameKey parent = config.getProject().getParent(allProjects);
       requireNonNull(parent);
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
index 3e1104e..2f264b5 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -22,21 +22,16 @@
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementJson;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -48,42 +43,22 @@
 @Singleton
 public class UpdateSubmitRequirement
     implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public UpdateSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<SubmitRequirementInfo> apply(
       SubmitRequirementResource rsrc, SubmitRequirementInput input)
-      throws AuthException, BadRequestException, PermissionBackendException, IOException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+      throws AuthException, BadRequestException, PermissionBackendException, IOException,
+          MethodNotAllowedException {
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -92,16 +67,17 @@
       throw new BadRequestException("name in input must match name in URL");
     }
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(),
+            null,
+            String.format("Update Submit Requirement %s", rsrc.getSubmitRequirement().name()))) {
+      ProjectConfig config = configUpdater.getConfig();
 
       SubmitRequirement submitRequirement =
-          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+          updateSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
 
-      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
-      config.commit(md);
-
-      projectCache.evict(rsrc.getProject().getNameKey());
+      configUpdater.commitConfigUpdate();
 
       return Response.created(SubmitRequirementJson.format(submitRequirement));
     } catch (ConfigInvalidException e) {
@@ -109,7 +85,7 @@
     }
   }
 
-  public SubmitRequirement createSubmitRequirement(
+  public SubmitRequirement updateSubmitRequirement(
       ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
     validateSRName(name);
     if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 123a873..55ec9b0 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 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.schema.AclUtil.block;
 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;
@@ -33,6 +34,8 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRule.Action;
 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.Sequence;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -44,6 +47,7 @@
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -131,11 +135,16 @@
       // init labels.
       input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
 
+      // init access sections.
       if (input.initDefaultAcls()) {
-        // init access sections.
         initDefaultAcls(config, input);
       }
 
+      // init submit requirement sections.
+      if (input.initDefaultSubmitRequirements()) {
+        initDefaultSubmitRequirements(config);
+      }
+
       // commit all the above configs as a commit in "refs/meta/config" branch of the All-Projects.
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
 
@@ -155,7 +164,10 @@
 
     config.upsertAccessSection(
         AccessSection.HEADS,
-        heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
+        heads -> {
+          initDefaultAclsForAnonymousUsers(heads, config);
+          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+        });
 
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
@@ -163,42 +175,64 @@
             input
                 .serviceUsersGroup()
                 .ifPresent(
-                    batchUsersGroup ->
-                        initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup)));
+                    serviceUsersGroup ->
+                        initDefaultAclsForServiceUsers(capabilities, config, serviceUsersGroup)));
+
+    input
+        .blockedUsersGroup()
+        .ifPresent(blockedUsersGrouo -> initDefaultAclsForBlockedUsers(config, blockedUsersGrouo));
 
     input
         .administratorsGroup()
         .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
-  private void initDefaultAclsForRegisteredUsers(
-      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
-    config.upsertAccessSection(
-        "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
+  private void initDefaultSubmitRequirements(ProjectConfig config) {
+    config.upsertSubmitRequirement(
+        SubmitRequirement.builder()
+            .setName("No-Unresolved-Comments")
+            .setDescription(
+                Optional.of("Changes that have unresolved comments are not submittable."))
+            .setApplicabilityExpression(SubmitRequirementExpression.of("has:unresolved"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  private void initDefaultAclsForAnonymousUsers(AccessSection.Builder heads, ProjectConfig config) {
+    grant(config, heads, Permission.READ, anonymous);
 
     config.upsertAccessSection(
         "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
+  }
 
+  private void initDefaultAclsForRegisteredUsers(
+      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, heads, Permission.READ, anonymous);
-    grant(config, heads, Permission.REVERT, registered);
 
     config.upsertAccessSection(
-        "refs/for/" + AccessSection.ALL,
-        magic -> {
-          grant(config, magic, Permission.PUSH, registered);
-          grant(config, magic, Permission.PUSH_MERGE, registered);
+        "refs/for/*",
+        refsFor -> {
+          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+          grant(config, refsFor, Permission.PUSH, registered);
+          grant(config, refsFor, Permission.PUSH_MERGE, registered);
         });
   }
 
-  private void initDefaultAclsForBatchUsers(
-      AccessSection.Builder capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+  private void initDefaultAclsForServiceUsers(
+      AccessSection.Builder capabilities, ProjectConfig config, GroupReference serviceUsersGroup) {
     Permission.Builder priority = capabilities.upsertPermission(GlobalCapability.PRIORITY);
-    priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH));
+    priority.add(rule(config, serviceUsersGroup).setAction(Action.BATCH));
 
     Permission.Builder stream = capabilities.upsertPermission(GlobalCapability.STREAM_EVENTS);
-    stream.add(rule(config, batchUsersGroup));
+    stream.add(rule(config, serviceUsersGroup));
+  }
+
+  private void initDefaultAclsForBlockedUsers(
+      ProjectConfig config, GroupReference blockedUsersGroup) {
+    config.upsertAccessSection(
+        AccessSection.ALL, all -> block(config, all, Permission.READ, blockedUsersGroup));
   }
 
   private void initDefaultAclsForAdmins(
@@ -216,10 +250,10 @@
         heads -> {
           grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
           grant(config, heads, Permission.CREATE, adminsGroup, owners);
-          grant(config, heads, Permission.PUSH, adminsGroup, owners);
           grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
           grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
           grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+          grant(config, heads, Permission.REVERT, adminsGroup, owners);
         });
 
     config.upsertAccessSection(
@@ -237,7 +271,6 @@
           grant(config, meta, Permission.READ, adminsGroup, owners);
           grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
           grant(config, meta, Permission.CREATE, adminsGroup, owners);
-          grant(config, meta, Permission.PUSH, adminsGroup, owners);
           grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
         });
   }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 8db5b1a..f692691 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -69,6 +69,9 @@
   /** The group which gets stream-events permission granted and appropriate properties set. */
   public abstract Optional<GroupReference> serviceUsersGroup();
 
+  /** The group for which read access gets blocked. */
+  public abstract Optional<GroupReference> blockedUsersGroup();
+
   /** The commit message used when commit the project config change. */
   public abstract Optional<String> commitMessage();
 
@@ -89,6 +92,9 @@
   /** Whether initializing default access sections in All-Projects. */
   public abstract boolean initDefaultAcls();
 
+  /** Whether default submit requirements should be initialized in All-Projects. */
+  public abstract boolean initDefaultSubmitRequirements();
+
   public abstract Builder toBuilder();
 
   public static Builder builder() {
@@ -96,7 +102,8 @@
         new AutoValue_AllProjectsInput.Builder()
             .codeReviewLabel(getDefaultCodeReviewLabel())
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
-            .initDefaultAcls(true);
+            .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true);
     DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(builder::addBooleanProjectConfig);
 
     return builder;
@@ -110,7 +117,9 @@
   public abstract static class Builder {
     public abstract Builder administratorsGroup(GroupReference adminGroup);
 
-    public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
+    public abstract Builder serviceUsersGroup(GroupReference serviceUsersGroup);
+
+    public abstract Builder blockedUsersGroup(GroupReference blockedUsersGroup);
 
     public abstract Builder commitMessage(String commitMessage);
 
@@ -135,6 +144,8 @@
     @UsedAt(UsedAt.Project.GOOGLE)
     public abstract Builder initDefaultAcls(boolean initDefaultACLs);
 
+    public abstract Builder initDefaultSubmitRequirements(boolean initDefaultSubmitRequirements);
+
     public abstract AllProjectsInput build();
   }
 }
diff --git a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
index d993c4a..9dca2d9 100644
--- a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
@@ -28,9 +28,7 @@
 @Singleton
 public class CloudSpannerAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
 
-  private static final int ERR_DUP_KEY = 1022;
-  private static final int ERR_DUP_ENTRY = 1062;
-  private static final int ERR_DUP_UNIQUE = 1169;
+  private static final int ERR_DUP_KEY = 6;
 
   @Inject
   CloudSpannerAccountPatchReviewStore(
@@ -44,8 +42,6 @@
   public StorageException convertError(String op, SQLException err) {
     switch (err.getErrorCode()) {
       case ERR_DUP_KEY:
-      case ERR_DUP_ENTRY:
-      case ERR_DUP_UNIQUE:
         return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
 
       default:
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 56c6fa8..fcd2264 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -54,6 +54,8 @@
 // confusing and could stand to be reworked. Another smell is that this is an interface only for
 // testing purposes.
 public class SchemaCreatorImpl implements SchemaCreator {
+  public static final String BLOCKED_USERS = "Blocked Users";
+
   private final GitRepositoryManager repoManager;
   private final AllProjectsCreator allProjectsCreator;
   private final AllUsersCreator allUsersCreator;
@@ -92,11 +94,13 @@
     try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
       GroupReference admins = createGroupReference("Administrators");
       GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+      GroupReference blockedUsers = createGroupReference(BLOCKED_USERS);
 
       AllProjectsInput allProjectsInput =
           AllProjectsInput.builder()
               .administratorsGroup(admins)
               .serviceUsersGroup(serviceUsers)
+              .blockedUsersGroup(blockedUsers)
               .build();
       allProjectsCreator.create(allProjectsInput);
       // We have to create the All-Users repository before we can use it to store the groups in it.
@@ -104,7 +108,8 @@
 
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
         createAdminsGroup(allUsersRepo, admins);
-        createBatchUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+        createServiceUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+        createBlockedUsersGroup(allUsersRepo, blockedUsers, admins.getUUID());
       }
     }
   }
@@ -127,7 +132,7 @@
     createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
-  private void createBatchUsersGroup(
+  private void createServiceUsersGroup(
       Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
       throws IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(groupReference);
@@ -135,6 +140,20 @@
         GroupDelta.builder()
             .setDescription("Users who perform batch actions on Gerrit")
             .setOwnerGroupUUID(adminsGroupUuid)
+            .setVisibleToAll(true)
+            .build();
+
+    createGroup(allUsersRepo, groupCreation, groupDelta);
+  }
+
+  private void createBlockedUsersGroup(
+      Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
+      throws IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(groupReference);
+    GroupDelta groupDelta =
+        GroupDelta.builder()
+            .setDescription("Blocked users. Add spammers to this group.")
+            .setOwnerGroupUUID(adminsGroupUuid)
             .build();
 
     createGroup(allUsersRepo, groupCreation, groupDelta);
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 7243bdf..650c425 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,15 @@
       ImmutableList.of(
           "[access \"refs/*\"]",
           "  read = group Administrators",
+          "  read = block group Blocked Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
-          "[access \"refs/for/refs/*\"]",
           "  push = group Registered Users",
           "  pushMerge = group Registered Users",
           "[access \"refs/heads/*\"]",
           "  read = group Anonymous Users",
-          "  revert = group Registered Users",
+          "  revert = group Administrators",
+          "  revert = group Project Owners",
           "  create = group Administrators",
           "  create = group Project Owners",
           "  editTopicName = +force group Administrators",
@@ -72,8 +73,6 @@
           "  label-Code-Review = -2..+2 group Administrators",
           "  label-Code-Review = -2..+2 group Project Owners",
           "  label-Code-Review = -1..+1 group Registered Users",
-          "  push = group Administrators",
-          "  push = group Project Owners",
           "  submit = group Administrators",
           "  submit = group Project Owners",
           "[access \"refs/meta/config\"]",
@@ -82,8 +81,6 @@
           "  create = group Project Owners",
           "  label-Code-Review = -2..+2 group Administrators",
           "  label-Code-Review = -2..+2 group Project Owners",
-          "  push = group Administrators",
-          "  push = group Project Owners",
           "  read = group Administrators",
           "  read = group Project Owners",
           "  submit = group Administrators",
@@ -108,6 +105,13 @@
           "  value = 0 No score",
           "  value = +1 Looks good to me, but someone else must approve",
           "  value = +2 Looks good to me, approved");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION =
+      ImmutableList.of(
+          "[submit-requirement \"No-Unresolved-Comments\"]",
+          "  description = Changes that have unresolved comments are not submittable.",
+          "  applicableIf = has:unresolved",
+          "  submittableIf = -has:unresolved",
+          "  canOverrideInChildProjects = false");
 
   public static String getDefaultAllProjectsWithAllDefaultSections() {
     return Streams.stream(
@@ -117,7 +121,8 @@
                 DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
                 DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
                 DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
-                DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
         .collect(Collectors.joining("\n"));
   }
 
@@ -127,6 +132,19 @@
                 DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
                 DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
                 DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
+  public static String getAllProjectsWithoutDefaultSubmitRequirements() {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+                DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
                 DEFAULT_ALL_PROJECTS_LABEL_SECTION))
         .collect(Collectors.joining("\n"));
   }
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 712715e..8bdf6fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -129,7 +130,10 @@
         err.append("error: insuffient access rights to change parent of '")
             .append(name)
             .append("'\n");
-      } catch (ResourceConflictException | ResourceNotFoundException | BadRequestException e) {
+      } catch (ResourceConflictException
+          | ResourceNotFoundException
+          | BadRequestException
+          | MethodNotAllowedException e) {
         err.append("error: ").append(e.getMessage()).append("'\n");
       } catch (UnprocessableEntityException | IOException e) {
         throw new Failure(1, "failure in request", e);
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 00361ad..14915bf 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -134,6 +134,7 @@
       switch (task.state) {
         case DONE:
         case CANCELLED:
+        case PARKED:
         case STARTING:
         case RUNNING:
         case STOPPING:
@@ -212,6 +213,8 @@
         return "";
       case STARTING:
         return "starting ...";
+      case PARKED:
+        return "parked .....";
       case READY:
         return "waiting ....";
       case SLEEPING:
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b0045e3..8e065b9 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -209,6 +210,7 @@
     install(new RepoSequenceModule());
     install(new NoteDbDraftCommentsModule());
     install(new NoteDbStarredChangesModule());
+    install(new ChangeCleanupRunnerModule());
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index fdf7457..b720a0dc 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -2765,10 +2765,10 @@
     int initialCommits = countExternalIdsCommits();
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+            "Add External ID", user.id(), u -> u.addExternalId(extId2));
     ImmutableList<Optional<AccountState>> accountStates =
         accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
     assertThat(accountStates).hasSize(2);
@@ -2811,11 +2811,11 @@
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
     // Another update for the same account is not allowed.
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId2));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId2));
     IllegalArgumentException e =
         assertThrows(
             IllegalArgumentException.class,
@@ -2834,10 +2834,10 @@
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extIdAdmin));
+            "Add External ID", admin.id(), u -> u.addExternalId(extIdAdmin));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), (a, u) -> u.addExternalId(extIdUser));
+            "Add External ID", user.id(), u -> u.addExternalId(extIdUser));
     DuplicateExternalIdKeyException e =
         assertThrows(
             DuplicateExternalIdKeyException.class,
@@ -2856,10 +2856,10 @@
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "first message", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "first message", admin.id(), u -> u.addExternalId(extId1));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "second message", user.id(), (a, u) -> u.addExternalId(extId2));
+            "second message", user.id(), u -> u.addExternalId(extId2));
     accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
 
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
@@ -2877,7 +2877,7 @@
         getExternalIdFactory()
             .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
 
-    accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+    accountsUpdateProvider.get().update("foobar", admin.id(), u -> u.addExternalId(extId));
 
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(allUsersRepo)) {
@@ -2997,7 +2997,7 @@
     ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
     accountsUpdateProvider
         .get()
-        .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
+        .update("Add External ID", admin.id(), u -> u.addExternalId(externalId));
     assertExternalIds(
         admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
 
@@ -3013,7 +3013,7 @@
     ExternalId externalId = createEmailExternalId(admin.id(), "admin@example.com");
     accountsUpdateProvider
         .get()
-        .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
+        .update("Remove External ID", admin.id(), u -> u.deleteExternalId(externalId));
     assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
@@ -3031,7 +3031,7 @@
                 SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
     accountsUpdateProvider
         .get()
-        .update("Update External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+        .update("Update External ID", admin.id(), u -> u.updateExternalId(externalId));
     assertExternalIds(
         admin.id(),
         ImmutableSet.of(
@@ -3055,11 +3055,7 @@
     accountsUpdateProvider
         .get()
         .update(
-            "Replace External ID",
-            admin.id(),
-            (a, u) -> {
-              u.replaceExternalId(oldExternalId, externalId);
-            });
+            "Replace External ID", admin.id(), u -> u.replaceExternalId(oldExternalId, externalId));
     assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
@@ -3089,10 +3085,10 @@
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+            "Add External ID", user.id(), u -> u.addExternalId(extId2));
     AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
@@ -3129,12 +3125,12 @@
     requestScopeOperations.setApiUser(admin.id());
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Update Display Name", admin.id(), (a, u) -> u.setDisplayName("DN"));
+            "Update Display Name", admin.id(), u -> u.setDisplayName("DN"));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
             "Remove external Id",
             user.id(),
-            (a, u) -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
+            u -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
     AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index ead4c40..f462614 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+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 java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
@@ -26,6 +28,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.AccountGroup;
@@ -33,6 +36,7 @@
 import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -63,6 +67,7 @@
   private ContributorAgreement caAutoVerify;
   private ContributorAgreement caNoAutoVerify;
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
@@ -298,6 +303,11 @@
     gApi.changes().id(change.changeId).current().submit(new SubmitInput());
 
     // Revert in excluded project is allowed even when CLA is required but not signed
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
     gApi.changes().id(change.changeId).revert();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 0b28f6f..afa9bca 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -85,6 +85,7 @@
     i.signedOffBy ^= true;
     i.allowBrowserNotifications ^= false;
     i.allowSuggestCodeWhileCommenting ^= false;
+    i.allowAutocompletingComments ^= false;
     i.diffPageSidebar = "plugin-insight";
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
@@ -99,6 +100,7 @@
     assertThat(o.theme).isEqualTo(i.theme);
     assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
     assertThat(o.allowSuggestCodeWhileCommenting).isEqualTo(i.allowSuggestCodeWhileCommenting);
+    assertThat(o.allowAutocompletingComments).isEqualTo(i.allowAutocompletingComments);
     assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index f0f262f..18969ad 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -40,7 +40,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -53,7 +53,7 @@
 import org.junit.Test;
 
 public class AbandonIT extends AbstractDaemonTest {
-  @Inject private AbandonUtil abandonUtil;
+  @Inject private ChangeCleanupRunner.Factory cleanupRunner;
   @Inject private ChangeCleanupConfig cleanupConfig;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -140,7 +140,7 @@
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
     assertThat(query("is:abandoned")).isEmpty();
 
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    cleanupRunner.create().run();
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
     assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
   }
@@ -182,7 +182,7 @@
     assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
     assertThat(toChangeNumbers(query("-is:mergeable"))).containsExactly(id4);
 
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    cleanupRunner.create().run();
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5, id2, id1);
     assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4);
   }
@@ -229,7 +229,7 @@
         assertThrows(BadRequestException.class, () -> query("-is:mergeable"));
     assertThat(thrown).hasMessageThat().contains("operator is not supported");
 
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    cleanupRunner.create().run();
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5);
     assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4, id2, id1);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index 78361a1..eff783e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -80,6 +80,18 @@
           + "+Second added line\n"
           + "\\ No newline at end of file\n";
 
+  private static final String CONFLICTING_FILE_NAME = "conflicting_file.txt";
+  private static final String CONFLICTING_FILE_ORIGINAL_CONTENT =
+      "First original line\nSecond original line";
+  private static final String CONFLICTING_FILE_DIFF =
+      "diff --git a/conflicting_file.txt b/conflicting_file.txt\n"
+          + "--- a/conflicting_file.txt\n"
+          + "+++ b/conflicting_file.txt\n"
+          + "@@ -1,2 +1 @@\n"
+          + "-First original line\n"
+          + "-Third original line\n"
+          + "+Modified line\n";
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
@@ -353,6 +365,36 @@
   }
 
   @Test
+  public void applyPatchWithConflict_createsConflictMarkers() throws Exception {
+    initBaseWithFile(CONFLICTING_FILE_NAME, CONFLICTING_FILE_ORIGINAL_CONTENT);
+    String patch = CONFLICTING_FILE_DIFF;
+    ApplyPatchPatchSetInput in = buildInput(patch);
+    in.commitMessage = "subject";
+    in.patch.allowConflicts = true;
+
+    ChangeInfo result = applyPatch(in);
+    assertThat(
+            gApi.changes()
+                .id(result.id)
+                .current()
+                .file("conflicting_file.txt")
+                .content()
+                .asString())
+        .isEqualTo(
+            "<<<<<<< HEAD\n"
+                + "First original line\n"
+                + "Second original line\n"
+                + "=======\n"
+                + "Modified line\n"
+                + ">>>>>>> PATCH");
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .contains(
+            "ATTENTION: Conflicts occurred while applying the patch.\n"
+                + "Please resolve conflict markers.");
+    assertThat(result.containsGitConflicts).isTrue();
+  }
+
+  @Test
   public void applyPatchWithConflict_appendErrorsToCommitMessageWithLargeOriginalPatch()
       throws Exception {
     initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 4758254..6e635c9 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -171,7 +171,11 @@
 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;
@@ -2320,7 +2324,7 @@
   }
 
   @Test
-  public void removeReviewerSelfFromMergedChangeNotPermitted() throws Exception {
+  public void removeReviewerSelfFromMergedChangeNotPossible() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
 
@@ -2332,11 +2336,11 @@
     gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
 
     requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
+    ResourceConflictException thrown =
         assertThrows(
-            AuthException.class,
+            ResourceConflictException.class,
             () -> gApi.changes().id(r.getChangeId()).reviewer("self").remove());
-    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
   }
 
   @Test
@@ -2521,7 +2525,25 @@
   }
 
   @Test
-  public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+  public void deleteVoteFromMergedChangeNotPossible() throws Exception {
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(admin.id().toString())
+                    .deleteVote(LabelId.CODE_REVIEW));
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
+  public void deleteVoteFromOpenChangeAlwaysPermittedForSelfVotes() throws Exception {
     projectOperations
         .project(project)
         .forUpdate()
@@ -2542,7 +2564,7 @@
     String changeId = r.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+    approve(changeId);
 
     gApi.changes()
         .id(r.getChangeId())
@@ -2551,7 +2573,38 @@
   }
 
   @Test
-  public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+  public void deleteVoteFromClosedChangeNotPossibleForSelfVotes() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(user.id().toString())
+                    .deleteVote(LabelId.CODE_REVIEW));
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
+  public void deleteVoteFromOpenChangeAlwaysPermittedForAdmin() throws Exception {
     projectOperations
         .project(project)
         .forUpdate()
@@ -2572,7 +2625,7 @@
     String changeId = r.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+    approve(changeId);
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.changes()
@@ -2582,6 +2635,38 @@
   }
 
   @Test
+  public void deleteVoteFromMergedChangeNotPossibleForAdmin() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).current().submit();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(user.id().toString())
+                    .deleteVote(LabelId.CODE_REVIEW));
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
         label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -4204,6 +4289,24 @@
   }
 
   @Test
+  public void changeCommitMessageInvokesCommitValidators() 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 message\nChange-Id: " + r.getChangeId() + "\n";
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+      assertThat(testCommitValidationListener.receiveEvent).isNotNull();
+    }
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -4595,6 +4698,11 @@
     gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
     int number = gApi.changes().id(change.getChangeId()).get()._number;
+
+    // Note: Computing the description of some UI actions does access the index. If the index is
+    // disabled computing these descriptions fails. UiActions#describe catches and ignores these
+    // exceptions so that the request is still successful. In this case the description of the UI
+    // action is omitted in the response.
     try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
@@ -4846,4 +4954,15 @@
   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/DefaultSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
new file mode 100644
index 0000000..fc8eaed
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
@@ -0,0 +1,79 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.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.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.Comparator;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class DefaultSubmitRequirementsIT extends AbstractDaemonTest {
+  /**
+   * Tests the "No-Unresolved-Comments" submit requirement that is created during the site
+   * initialization.
+   */
+  @Test
+  public void cannotSubmitChangeWithUnresolvedComment() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+    PushOneCommit.Result r =
+        createChange(repo, "master", "Add a file", "foo", "content", /* topic= */ null);
+    String changeId = r.getChangeId();
+    CommentInfo commentInfo =
+        addComment(changeId, "foo", "message", /* unresolved= */ true, /* inReplyTo= */ null);
+    assertThat(commentInfo.unresolved).isTrue();
+    approve(changeId);
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %s: submit requirement 'No-Unresolved-Comments' is unsatisfied.",
+                r.getChange().getId().get()));
+
+    // Resolve the comment and check that the change can be submitted now.
+    CommentInfo commentInfo2 =
+        addComment(
+            changeId, "foo", "reply", /* unresolved= */ false, /* inReplyTo= */ commentInfo.id);
+    assertThat(commentInfo2.unresolved).isFalse();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @CanIgnoreReturnValue
+  private CommentInfo addComment(
+      String changeId, String file, String message, boolean unresolved, @Nullable String inReplyTo)
+      throws Exception {
+    ReviewInput in = new ReviewInput();
+    CommentInput commentInput = new CommentInput();
+    commentInput.path = file;
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.unresolved = unresolved;
+    commentInput.inReplyTo = inReplyTo;
+    in.comments = ImmutableMap.of(file, ImmutableList.of(commentInput));
+    gApi.changes().id(changeId).current().review(in);
+
+    return gApi.changes().id(changeId).commentsRequest().getAsList().stream()
+        .filter(commentInfo -> commentInput.message.equals(commentInfo.message))
+        // if there are multiple comments with the same message, take the one was created last
+        .max(
+            Comparator.comparing(commentInfo1 -> commentInfo1.updated.toInstant().getEpochSecond()))
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format("comment '%s' not found", commentInput.message)));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
new file mode 100644
index 0000000..07d32fe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.DefaultPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class QueryChangesFilterPermissionBackendIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Singleton
+  public static class TestPermissionBackend extends PermissionBackend {
+    private final DefaultPermissionBackend defaultPermissionBackend;
+    private final AtomicReference<String> extraQueryFilter;
+
+    public static class Module extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(PermissionBackend.class).to(TestPermissionBackend.class).in(Scopes.SINGLETON);
+      }
+    }
+
+    @Inject
+    TestPermissionBackend(DefaultPermissionBackend defaultPermissionBackend) {
+      this.defaultPermissionBackend = defaultPermissionBackend;
+      this.extraQueryFilter = new AtomicReference<>();
+    }
+
+    @Override
+    public WithUser currentUser() {
+      return new TestPermissionWithUser(defaultPermissionBackend.currentUser());
+    }
+
+    @Override
+    public WithUser user(CurrentUser user) {
+      return new TestPermissionWithUser(defaultPermissionBackend.user(user));
+    }
+
+    @Override
+    public WithUser absentUser(Account.Id id) {
+      return new TestPermissionWithUser(defaultPermissionBackend.absentUser(id));
+    }
+
+    public String getExtraQueryFilter() {
+      return extraQueryFilter.get();
+    }
+
+    public void setExtraQueryFilter(String extraQueryFilter) {
+      this.extraQueryFilter.set(extraQueryFilter);
+    }
+
+    class TestPermissionWithUser extends WithUser {
+
+      private final WithUser defaultPermissioBackendWithUser;
+
+      TestPermissionWithUser(WithUser defaultPermissioBackendWithUser) {
+        this.defaultPermissioBackendWithUser = defaultPermissioBackendWithUser;
+      }
+
+      @Override
+      public ForProject project(Project.NameKey project) {
+        return defaultPermissioBackendWithUser.project(project);
+      }
+
+      @Override
+      public void check(GlobalOrPluginPermission perm)
+          throws AuthException, PermissionBackendException {
+        defaultPermissioBackendWithUser.check(perm);
+      }
+
+      @Override
+      public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+          throws PermissionBackendException {
+        return defaultPermissioBackendWithUser.test(permSet);
+      }
+
+      @Override
+      public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+        return defaultPermissioBackendWithUser.testCond(perm);
+      }
+
+      @Override
+      public String filterQueryChanges() {
+        return extraQueryFilter.get();
+      }
+    }
+  }
+
+  @Override
+  public Module createModule() {
+    return new TestPermissionBackend.Module();
+  }
+
+  @Test
+  public void filterHidenProjectByAuthenticationBackend() throws Exception {
+    String projectChangeId = createChange().getChangeId();
+
+    Project.NameKey hiddenProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+    createChange(hiddenRepo);
+
+    assertThat(gApi.changes().query().get()).hasSize(2);
+
+    server
+        .getTestInjector()
+        .getInstance(TestPermissionBackend.class)
+        .setExtraQueryFilter("-project:" + hiddenProject);
+    List<ChangeInfo> projectChanges = gApi.changes().query().get();
+    assertThat(projectChanges.stream().map(c -> c.changeId)).containsExactly(projectChangeId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 7f21eb6..a5de579 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -858,6 +858,24 @@
     }
 
     @Test
+    public void rebaseChangeWithRefsHeadsMaster() throws Exception {
+      RevCommit desiredBase =
+          createNewCommitWithoutChangeId(/*branch=*/ "refs/heads/master", "file", "content");
+      PushOneCommit.Result child = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase child onto desiredBase (referenced by ref)
+      ri.base = "refs/heads/master";
+      rebaseCallWithInput.call(child.getChangeId(), ri);
+
+      PatchSet ps2 = child.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+      RevisionInfo childInfo =
+          get(child.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(childInfo.commit.parents.get(0).commit).isEqualTo(desiredBase.name());
+    }
+
+    @Test
     public void cannotRebaseChangeWithInvalidBaseCommit() throws Exception {
       // Create another branch and push the desired parent commit to it.
       String branchName = "foo";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index cd3e76d..10dd5e3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.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.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -413,6 +414,12 @@
   @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void revertWithNonVisibleUsers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     // Define readable names for the users we use in this test.
     TestAccount reverter = user;
     TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 42af666..a5d86ce 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -92,6 +92,7 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
@@ -103,6 +104,11 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
 
+  @Before
+  public void setup() throws RestApiException {
+    removeDefaultSubmitRequirements();
+  }
+
   @Test
   public void submitRecords() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -3160,4 +3166,8 @@
     r.assertOkStatus();
     return r;
   }
+
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index fe3cb00..cef9ddd 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -52,6 +52,7 @@
                 .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.DISABLE_CHANGE_ETAGS,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
         .inOrder();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 035b567..72b3b39 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+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.data.GlobalCapability;
@@ -57,6 +58,7 @@
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -1179,6 +1181,33 @@
         new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false));
   }
 
+  @Test
+  public void canUpdateConfigWithoutCreatingChangeNullByDefault() throws Exception {
+    assertThat(pApi().access().requireChangeForConfigUpdate).isNull();
+    assertThat(gApi.projects().name(allProjects.get()).access().requireChangeForConfigUpdate)
+        .isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void canUpdateConfigWithoutCreatingChangeUsesConfigValue() throws Exception {
+    assertThat(pApi().access().requireChangeForConfigUpdate).isTrue();
+    assertThat(gApi.projects().name(allProjects.get()).access().requireChangeForConfigUpdate)
+        .isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_postAccessRejected() {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    MethodNotAllowedException e =
+        assertThrows(MethodNotAllowedException.class, () -> pApi().access(accessInput));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
   private ProjectApi pApi() throws Exception {
     return gApi.projects().name(newProjectName.get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
new file mode 100644
index 0000000..694cfc9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ConfigReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private Project.NameKey defaultMessageProject;
+  private Project.NameKey customMessageProject;
+
+  @Before
+  public void setUp() throws Exception {
+    defaultMessageProject = projectOperations.newProject().create();
+    customMessageProject = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void createConfigChangeWithDefaultMessage() throws Exception {
+    ConfigInput in = new ConfigInput();
+    in.description = "Test project description";
+
+    ChangeInfo changeInfo = gApi.projects().name(defaultMessageProject.get()).configReview(in);
+
+    assertThat(changeInfo.subject).isEqualTo("Review config change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("project", null, "description"))
+        .isEqualTo("Test project description");
+  }
+
+  @Test
+  public void createConfigChangeWithCustomMessage() throws Exception {
+    ConfigInput in = new ConfigInput();
+    in.description = "Test project description";
+    String customMessage = "test custom message";
+    in.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(customMessageProject.get()).configReview(in);
+
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("project", null, "description"))
+        .isEqualTo("Test project description");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index b9cbbcd..8f96f6a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -23,13 +23,16 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+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;
@@ -102,19 +105,39 @@
     DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().dashboard(info.id).setDefault();
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG,
+        "Administrator",
+        String.format("Changed default dashboard to %s.", info.id));
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
     assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
   }
 
   @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setDefaultDashboardReject() throws Exception {
+    DashboardInfo info = createTestDashboard();
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class, () -> project().dashboard(info.id).setDefault());
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
   public void setDefaultDashboardByProject() throws Exception {
     DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().defaultDashboard(info.id);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG,
+        "Administrator",
+        String.format("Changed default dashboard to %s.", info.id));
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
     assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
 
     project().removeDefaultDashboard();
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Removed default dashboard.");
     assertThat(project().dashboard(info.id).get().isDefault).isNull();
 
     assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
diff --git a/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java
new file mode 100644
index 0000000..8e64325
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class LabelsReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void createLabelsChangeWithDefaultMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput);
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).labelsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo("Review labels change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getStringList("label", "Foo", "value"))
+        .asList()
+        .containsExactly("+1 Looks Good", "0 Don't Know", "-1 Looks Bad");
+  }
+
+  @Test
+  public void createLabelsChangeWithCustomMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput);
+    String customMessage = "test custom message";
+    input.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).labelsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getStringList("label", "Foo", "value"))
+        .asList()
+        .containsExactly("+1 Looks Good", "0 Don't Know", "-1 Looks Bad");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index e34b985..80d6b5c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -70,6 +70,7 @@
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -393,6 +394,17 @@
     assertThat(info.state).isEqualTo(input.state);
   }
 
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setConfigRejected() {
+    ConfigInput input = createTestConfigInput();
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).config(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
   @SuppressWarnings("deprecation")
   @Test
   public void setPartialConfig() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
new file mode 100644
index 0000000..34af339
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import org.junit.Test;
+
+public class PutDescriptionIT extends AbstractDaemonTest {
+  @Test
+  public void setDescription() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description";
+    gApi.projects().name(project.get()).description(input);
+    assertThat(gApi.projects().name(project.get()).description())
+        .isEqualTo("test project description");
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Update description");
+  }
+
+  @Test
+  public void setDescriptionWithCustomCommitMessage() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description with test commit message";
+    input.commitMessage = "test commit message";
+    gApi.projects().name(project.get()).description(input);
+    assertThat(gApi.projects().name(project.get()).description())
+        .isEqualTo("test project description with test commit message");
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "test commit message");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setDescription() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description";
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).description(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 2bdbe50..8e1cbe5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -89,12 +91,16 @@
 
     gApi.projects().name(project.get()).parent(parent);
     assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", String.format("Changed parent to %s.", parent));
 
     // When the parent name is not explicitly set, it should be
     // set to "All-Projects".
     gApi.projects().name(project.get()).parent(null);
     assertThat(gApi.projects().name(project.get()).parent())
         .isEqualTo(AllProjectsNameProvider.DEFAULT);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Changed parent to All-Projects.");
   }
 
   @Test
@@ -169,4 +175,16 @@
             BadRequestException.class, () -> gApi.projects().name(allUsers.get()).parent(parent));
     assertThat(thrown).hasMessageThat().contains("All-Users must inherit from All-Projects");
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_postParentRejected() {
+    String parent = projectOperations.newProject().create().get();
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).parent(parent));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
index e388dd1..933b538 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -20,15 +20,21 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+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.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
@@ -582,7 +588,7 @@
 
     infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
 
-    assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+    assertThat(names(infos)).containsExactly("No-Unresolved-Comments", "base-sr", "sr-1", "sr-2");
   }
 
   @Test
@@ -639,6 +645,53 @@
     assertThat(names(infos)).containsExactly("verified");
   }
 
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_createSubmitRequirementRejected() {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").create(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_updateSubmitRequirementRejected() throws Exception {
+    createSubmitRequirementWithReview(project.get(), "code-review");
+
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_deleteSubmitRequirementRejected() throws Exception {
+    createSubmitRequirementWithReview(project.get(), "code-review");
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").delete());
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
   private SubmitRequirementInfo createSubmitRequirement(String srName) throws RestApiException {
     return createSubmitRequirement(project.get(), srName);
   }
@@ -652,6 +705,21 @@
     return gApi.projects().name(project).submitRequirement(srName).create(input).get();
   }
 
+  private void createSubmitRequirementWithReview(String project, String srName)
+      throws RestApiException {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = srName;
+    input.submittabilityExpression = "label:dummy=+2";
+    BatchSubmitRequirementInput batchInput = new BatchSubmitRequirementInput();
+    batchInput.create = ImmutableList.of(input);
+
+    ChangeInfo change = gApi.projects().name(project).submitRequirementsReview(batchInput);
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.label("Code-Review", 2);
+    gApi.changes().id(change.project, change._number).current().review(reviewInput);
+    gApi.changes().id(change.project, change._number).current().submit();
+  }
+
   private List<String> names(List<SubmitRequirementInfo> infos) {
     return infos.stream().map(sr -> sr.name).collect(Collectors.toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
new file mode 100644
index 0000000..8c8e7a6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class SubmitRequirementsReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void createSubmitRequirementsChangeWithDefaultMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo("Review submit requirements change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("submit-requirement", "Foo", "description"))
+        .isEqualTo("SR description");
+    assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+        .isEqualTo("topic:foo");
+    assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+        .isEqualTo("label:code-review=+2");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_batchUpdateRejected() {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(testProject.get()).submitRequirements(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
+  public void createSubmitRequirementsChangeWithCustomMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+    String customMessage = "test custom message";
+    input.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("submit-requirement", "Foo", "description"))
+        .isEqualTo("SR description");
+    assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+        .isEqualTo("topic:foo");
+    assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+        .isEqualTo("label:code-review=+2");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 62a095f..9f673c8 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -103,6 +103,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 String FILE_NAME4 = "foo4";
   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);
@@ -348,6 +349,16 @@
   }
 
   @Test
+  public void updateMultipleExistingFiles() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME2, RawInputUtil.create(CONTENT_NEW));
+    assertThat(getEdit(changeId)).isPresent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME2), CONTENT_NEW);
+  }
+
+  @Test
   public void updateExistingFileAfterUpdatingPreferredEmail() throws Exception {
     String emailOne = "email1@example.com";
     Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
@@ -1182,6 +1193,41 @@
   }
 
   @Test
+  public void addMultipleNewFiles() throws Exception {
+    createEmptyEditFor(changeId);
+    Optional<EditInfo> originalEdit =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(originalEdit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2);
+
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME4, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME4), CONTENT_NEW);
+
+    Optional<EditInfo> adjustedEdit =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(adjustedEdit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3, FILE_NAME4);
+  }
+
+  @Test
   public void addNewFileAndAmend() throws Exception {
     createEmptyEditFor(changeId);
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
@@ -1371,8 +1417,21 @@
 
   @Test
   public void canCombineEdits() throws Exception {
-    createEmptyEditFor(changeId);
+    String baseFileToDelete = "base_file_to_delete";
+    String baseFileToRename = "base_file_to_rename";
+    String baseFileNewName = "base_file_new_name";
+    String currPatchSetFileToRename = "current_patchset_file_to_rename";
+    String currPatchSetFileNewName = "current_patchset_file_new_name";
+    // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
+    testRepo = cloneProject(project);
+    String baseChangeId = newChangeWithFile(admin.newIdent(), baseFileToDelete, "content");
+    addNewPatchSetWithModifiedFile(baseChangeId, baseFileToRename, "content2");
+    gApi.changes().id(baseChangeId).current().review(ReviewInput.approve());
+    gApi.changes().id(baseChangeId).current().submit();
+    String changeId = newChange(admin.newIdent());
+    addNewPatchSetWithModifiedFile(changeId, currPatchSetFileToRename, "content3");
 
+    createEmptyEditFor(changeId);
     // update author
     gApi.changes()
         .id(changeId)
@@ -1394,11 +1453,20 @@
         .modifyIdentity(
             "Test Committer", "test.committer@example.com", ChangeEditIdentityType.COMMITTER);
 
-    // delete file
+    // delete current patch-set file
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
 
-    // rename file
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME2, FILE_NAME3);
+    // delete base file
+    gApi.changes().id(changeId).edit().deleteFile(baseFileToDelete);
+
+    // rename current patch-set file
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .renameFile(currPatchSetFileToRename, currPatchSetFileNewName);
+
+    // rename base file
+    gApi.changes().id(changeId).edit().renameFile(baseFileToRename, baseFileNewName);
 
     // publish edit
     PublishChangeEditInput publishInput = new PublishChangeEditInput();
@@ -1419,7 +1487,12 @@
     assertThat(currentCommit.author.name).isEqualTo("Test Author");
     assertThat(currentCommit.author.email).isEqualTo("test.author@example.com");
     assertThat(currentCommit.message).isEqualTo(msg);
-    assertThat(currentRevision.files.keySet()).containsExactly(newFile, FILE_NAME3);
+    assertThat(currentRevision.files.keySet())
+        .containsExactly(newFile, baseFileToDelete, baseFileNewName, currPatchSetFileNewName);
+    assertThat(currentRevision.files.get(newFile).status).isEqualTo('A');
+    assertThat(currentRevision.files.get(baseFileToDelete).status).isEqualTo('D');
+    assertThat(currentRevision.files.get(baseFileNewName).status).isEqualTo('R');
+    assertThat(currentRevision.files.get(currPatchSetFileNewName).status).isEqualTo('A');
   }
 
   private void createArbitraryEditFor(String changeId) throws Exception {
@@ -1451,6 +1524,13 @@
     return push.to("refs/for/master").getChangeId();
   }
 
+  private String newChangeWithFile(PersonIdent ident, String filePath, String fileContent)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(ident, testRepo, PushOneCommit.SUBJECT, filePath, fileContent);
+    return push.to("refs/for/master").getChangeId();
+  }
+
   private void addNewPatchSet(String changeId) throws Exception {
     addNewPatchSetWithModifiedFile(changeId, FILE_NAME2, new String(CONTENT_NEW2, UTF_8));
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 8861a9e..aa96d1b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1028,6 +1028,19 @@
 
     // verify that the re-indexing was triggered for the change
     assertThat(query("has:edit")).hasSize(1);
+
+    // update the existing edit
+    r = amendChange(r.getChangeId(), "refs/for/master%edit");
+    r.assertOkStatus();
+    r.assertMessage(
+        canonicalWebUrl.get()
+            + "c/"
+            + project.get()
+            + "/+/"
+            + r.getChange().getId()
+            + " "
+            + editInfo.commit.subject
+            + " [EDIT]\n");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 6c5febd..c9f469e 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -87,7 +88,8 @@
   @Inject private IndexOperations.Change changeIndexOperations;
 
   private AccountGroup.UUID admins;
-  private AccountGroup.UUID nonInteractiveUsers;
+  private AccountGroup.UUID serviceUsers;
+  private AccountGroup.UUID blockedUsers;
 
   private RevCommit rcMaster;
   private RevCommit rcBranch;
@@ -118,7 +120,8 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+    serviceUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+    blockedUsers = groupUuid(SchemaCreatorImpl.BLOCKED_USERS);
     setUpPermissions();
     setUpChanges();
   }
@@ -1239,7 +1242,8 @@
       assertThat(getGroupRefs(git))
           .containsExactly(
               RefNames.refsGroups(admins),
-              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(serviceUsers),
+              RefNames.refsGroups(blockedUsers),
               RefNames.refsGroups(users));
     }
   }
@@ -1261,7 +1265,8 @@
       assertThat(getGroupRefs(git))
           .containsExactly(
               RefNames.refsGroups(admins),
-              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(serviceUsers),
+              RefNames.refsGroups(blockedUsers),
               RefNames.refsGroups(users));
     }
   }
@@ -1413,7 +1418,8 @@
             RefNames.REFS_EXTERNAL_IDS,
             RefNames.REFS_GROUPNAMES,
             RefNames.refsGroups(admins),
-            RefNames.refsGroups(nonInteractiveUsers),
+            RefNames.refsGroups(serviceUsers),
+            RefNames.refsGroups(blockedUsers),
             RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS,
             RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS,
             RefNames.REFS_CONFIG,
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 2d73e97..568b2da 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -386,7 +386,8 @@
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new10");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce())
+          .logNanos(anyString(), anyLong(), any());
     }
   }
 
@@ -399,7 +400,8 @@
       PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
-      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce())
+          .logNanos(anyString(), anyLong(), any());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 6667421..bfc64a6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -63,9 +63,11 @@
           RestCall.put("/config/server/preferences.edit"),
           RestCall.post("/config/server/reload"),
           RestCall.get("/config/server/summary"),
+          RestCall.post("/config/server/deactivate.stale.accounts"),
           RestCall.get("/config/server/tasks"),
           RestCall.get("/config/server/top-menus"),
-          RestCall.get("/config/server/version"));
+          RestCall.get("/config/server/version"),
+          RestCall.post("/config/server/cleanup.changes"));
 
   /**
    * Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index db9c1e7..18c435f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -70,6 +70,7 @@
           RestCall.get("/projects/%s/commits:in"),
           RestCall.get("/projects/%s/config"),
           RestCall.put("/projects/%s/config"),
+          RestCall.put("/projects/%s/config:review"),
           RestCall.post("/projects/%s/create.change"),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.get("/projects/%s/description"),
@@ -82,12 +83,15 @@
           RestCall.post("/projects/%s/index.changes"),
           RestCall.get("/projects/%s/labels"),
           RestCall.post("/projects/%s/labels/"),
+          RestCall.post("/projects/%s/labels:review"),
           RestCall.put("/projects/%s/labels/new-label"),
           RestCall.get("/projects/%s/parent"),
           RestCall.put("/projects/%s/parent"),
           RestCall.get("/projects/%s/statistics.git"),
           RestCall.get("/projects/%s/submit_requirements"),
           RestCall.put("/projects/%s/submit_requirements/new-sr"),
+          RestCall.post("/projects/%s/submit_requirements/"),
+          RestCall.post("/projects/%s/submit_requirements:review"),
           RestCall.get("/projects/%s/tags"),
           RestCall.put("/projects/%s/tags/new-tag"),
           RestCall.post("/projects/%s/tags:delete"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java
new file mode 100644
index 0000000..c33de03
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class ChangeEditIT extends AbstractDaemonTest {
+  private static final String FILE_NAME = "foo";
+  private static final String FILE_NAME2 = "foo2";
+
+  private static final String FILE_CONTENT = "content";
+  private static final String FILE_CONTENT2 = "content2";
+
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  public void modifyMultipleFilesInOneChangeEdit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    RestResponse response =
+        adminRestSession.putRaw(
+            String.format("/changes/%s/edit/%s", changeId, FILE_NAME),
+            RawInputUtil.create(FILE_CONTENT));
+    assertThat(response.getStatusCode()).isEqualTo(204);
+    RestResponse response2 =
+        adminRestSession.putRaw(
+            String.format("/changes/%s/edit/%s", changeId, FILE_NAME2),
+            RawInputUtil.create(FILE_CONTENT2));
+    assertThat(response2.getStatusCode()).isEqualTo(204);
+    RestResponse publishResponse =
+        adminRestSession.post(String.format("/changes/%s/edit:publish", changeId));
+    assertThat(publishResponse.getStatusCode()).isEqualTo(204);
+    assertThat(gApi.changes().id(changeId.get()).current().files().keySet())
+        .containsExactly("/COMMIT_MSG", FILE_NAME, FILE_NAME2);
+    // Created an initial change, then applied a single edit with two files resulting in one more
+    // patchset.
+    assertThat(gApi.changes().id(changeId.get()).get().currentRevisionNumber).isEqualTo(2);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeEtagIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEtagIT.java
new file mode 100644
index 0000000..6df8c86
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEtagIT.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.common.net.HttpHeaders;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class ChangeEtagIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.DISABLE_CHANGE_ETAGS)
+  public void changeEtagsDisabled() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    RestResponse response = adminRestSession.get(String.format("/changes/%s", changeId));
+    assertThat(response.getHeader(HttpHeaders.ETAG)).isNull();
+
+    response = adminRestSession.get(String.format("/changes/%s/detail", changeId));
+    assertThat(response.getHeader(HttpHeaders.ETAG)).isNull();
+  }
+
+  @Test
+  public void changeEtagsEnabled() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    RestResponse response = adminRestSession.get(String.format("/changes/%s", changeId));
+    assertThat(response.getHeader(HttpHeaders.ETAG)).isNotNull();
+
+    response = adminRestSession.get(String.format("/changes/%s/detail", changeId));
+    assertThat(response.getHeader(HttpHeaders.ETAG)).isNotNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index ff4fa9d..d618e2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
@@ -728,11 +729,11 @@
     gApi.changes().id(r.getChangeId()).current().submit();
 
     assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
-    AuthException thrown =
+    ResourceConflictException thrown =
         assertThrows(
-            AuthException.class,
+            ResourceConflictException.class,
             () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
-    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
   }
 
   @Test
@@ -755,11 +756,11 @@
     requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
 
-    AuthException thrown =
+    ResourceConflictException thrown =
         assertThrows(
-            AuthException.class,
+            ResourceConflictException.class,
             () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
-    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
   }
 
   @Test
@@ -837,8 +838,79 @@
   }
 
   @Test
+  public void removeReviewerWithVoteFromMergedChangeFailsWithRemoveReviewerPermission()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
+  public void removeSubmitterFromMergedChangeFailsWithRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
   @Sandboxed
-  public void removeReviewerWithoutVoteWithPermissionSucceeds() throws Exception {
+  public void removeReviewerWithoutVoteFromOpenChangeWithPermissionSucceeds() throws Exception {
     PushOneCommit.Result r = createChange();
     // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
     // rather than bypassing the check because of project or ref ownership.
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 698eac8..1e03f2d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -1174,7 +1174,7 @@
     ApplyPatchInput applyPatchInput = new ApplyPatchInput();
     applyPatchInput.patch = PATCH_INPUT;
     CommitTreeSupplier commitTreeSupplier =
-        (repo, oi, in, mergeTip) ->
+        (repo, oi, or, in, mergeTip) ->
             ApplyPatchUtil.applyPatch(repo, oi, applyPatchInput, mergeTip).getTreeId();
 
     ChangeInfo info = assertCreateWithCommitTreeSupplierSucceeds(input, commitTreeSupplier);
@@ -1186,6 +1186,52 @@
   }
 
   @Test
+  public void changePatch_multipleParents_success() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=1");
+    patchResp.assertOK();
+    assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+        .contains("+B content");
+
+    patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=2");
+    patchResp.assertOK();
+    assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+        .contains("+A content");
+  }
+
+  @Test
+  public void changePatch_multipleParents_failure() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + change.id + "/revisions/current/patch");
+    // Maintaining historic logic of failing with 409 Conflict in this case.
+    patchResp.assertConflict();
+  }
+
+  @Test
+  public void changePatch_parent_badRequest() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=3");
+    // Parent 3 does not exist.
+    patchResp.assertBadRequest();
+
+    patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=0");
+    // Parent 0 does not exist.
+    patchResp.assertBadRequest();
+  }
+
+  @Test
   @UseSystemTime
   public void sha1sOfTwoNewChangesDiffer() throws Exception {
     ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 26e37f4..ace7b52 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -42,15 +42,15 @@
 
   @Test
   public void metaDiff() throws Exception {
-    PushOneCommit.Result ch = createChange();
-    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
-    chApi.topic(TOPIC);
-    ChangeInfo oldInfo = chApi.get();
-    chApi.topic(TOPIC + "-2");
-    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
-    ChangeInfo newInfo = chApi.get();
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).topic(TOPIC);
+    ChangeInfo oldInfo = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).topic(TOPIC + "-2");
+    gApi.changes().id(changeId).setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = gApi.changes().id(changeId).get();
 
-    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+    ChangeInfoDifference difference =
+        gApi.changes().id(changeId).metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
 
     assertThat(difference.added().topic).isEqualTo(newInfo.topic);
     assertThat(difference.added().hashtags).isNotNull();
@@ -161,13 +161,12 @@
 
   @Test
   public void metaDiffNoOldMetaGivenUsesPatchSetBeforeNew() throws Exception {
-    PushOneCommit.Result ch = createChange();
-    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
-    chApi.topic(TOPIC);
-    ChangeInfo newInfo = chApi.get();
-    chApi.topic(TOPIC + "2");
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).topic(TOPIC);
+    ChangeInfo newInfo = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).topic(TOPIC + "2");
 
-    ChangeInfoDifference difference = chApi.metaDiff(null, newInfo.metaRevId);
+    ChangeInfoDifference difference = gApi.changes().id(changeId).metaDiff(null, newInfo.metaRevId);
 
     assertThat(difference.added().topic).isEqualTo(TOPIC);
     assertThat(difference.removed().topic).isNull();
@@ -202,17 +201,18 @@
 
   @Test
   public void metaDiffWithOptionIncludesExtraInformation() throws Exception {
-    PushOneCommit.Result ch = createChange();
-    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
-    ChangeInfo oldInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
-    amendChange(ch.getChangeId());
-    ChangeInfo newInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+    String changeId = createChange().getChangeId();
+    ChangeInfo oldInfo = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+    amendChange(changeId);
+    ChangeInfo newInfo = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
 
     ChangeInfoDifference difference =
-        chApi.metaDiff(
-            oldInfo.metaRevId,
-            newInfo.metaRevId,
-            ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
+        gApi.changes()
+            .id(changeId)
+            .metaDiff(
+                oldInfo.metaRevId,
+                newInfo.metaRevId,
+                ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
 
     assertThat(newInfo.currentRevision).isNotNull();
     assertThat(oldInfo.currentRevision).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 4453345..00d8f4e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import org.junit.Test;
@@ -34,6 +35,7 @@
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
     assertThat(groupMap.keySet())
-        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
+        .containsExactly(
+            "Administrators", SchemaCreatorImpl.BLOCKED_USERS, ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 462c76f..6b61252 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+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.LabelFunction;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.inject.Inject;
 import org.junit.Test;
@@ -512,4 +514,18 @@
     assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Add Foo Label");
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_createLabelRejected() {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.description = "Foo label description";
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 6a8c9d8..4597819 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -21,12 +21,14 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+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.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
 import org.junit.Test;
@@ -123,4 +125,14 @@
     @SuppressWarnings("unused")
     var unused = gApi.changes().id(changeId).get(DETAILED_LABELS);
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_deleteLabelRejected() {
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
new file mode 100644
index 0000000..adc0a8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
@@ -0,0 +1,386 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.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.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostSubmitRequirementsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .submitRequirements(new BatchSubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .submitRequirements(new BatchSubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void deleteNonExistingSR() throws Exception {
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void deleteSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo", "Bar");
+    gApi.projects().name(project.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+  }
+
+  @Test
+  public void deleteSR_namesAreTrimmed() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of(" Foo ", " Bar ");
+    gApi.projects().name(project.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteTheSameSRTwice() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo", "Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void cannotCreateSRWithNameThatIsAlreadyInUse() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    SubmitRequirementInput srInput = new SubmitRequirementInput();
+    srInput.name = "Foo";
+    srInput.allowOverrideInChildProjects = false;
+    srInput.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(srInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void cannotCreateTwoSRWithTheSameName() throws Exception {
+    SubmitRequirementInput srInput = new SubmitRequirementInput();
+    srInput.name = "Foo";
+    srInput.allowOverrideInChildProjects = false;
+    srInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(srInput, srInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void cannotCreateTwoSrWithConflictingNames() throws Exception {
+    SubmitRequirementInput sr1Input = new SubmitRequirementInput();
+    sr1Input.name = "Foo";
+    sr1Input.allowOverrideInChildProjects = false;
+    sr1Input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInput sr2Input = new SubmitRequirementInput();
+    sr2Input.name = "foo";
+    sr2Input.allowOverrideInChildProjects = false;
+    sr2Input.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(sr1Input, sr2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void createSubmitRequirements() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInput barInput = new SubmitRequirementInput();
+    barInput.name = "Bar";
+    barInput.allowOverrideInChildProjects = false;
+    barInput.submittabilityExpression = "label:code-review=+1";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void cannotCreateSRWithIncorrectName() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo ";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooInput, fooInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Name can only consist of alphanumeric characters");
+  }
+
+  @Test
+  public void cannotCreateSRWithoutName() throws Exception {
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(new SubmitRequirementInput());
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Empty submit requirement name");
+  }
+
+  @Test
+  public void updateNonExistingSR() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo2";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.update = ImmutableMap.of("Foo", fooInput);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void updateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    SubmitRequirementInput barUpdate = new SubmitRequirementInput();
+    barUpdate.name = "Baz";
+    barUpdate.submittabilityExpression = "label:code-review=+1";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+    gApi.projects().name(project.get()).submitRequirements(input);
+
+    assertThat(gApi.projects().name(project.get()).submitRequirement("Foo").get().description)
+        .isEqualTo(fooUpdate.description);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .submitRequirement("Foo")
+                .get()
+                .submittabilityExpression)
+        .isEqualTo(fooUpdate.submittabilityExpression);
+    assertThat(gApi.projects().name(project.get()).submitRequirement("Baz").get()).isNotNull();
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .submitRequirement("Baz")
+                .get()
+                .submittabilityExpression)
+        .isEqualTo(barUpdate.submittabilityExpression);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).submitRequirement("Bar").get());
+  }
+
+  @Test
+  public void deleteAndRecreateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooUpdate);
+
+    gApi.projects().name(project.get()).submitRequirements(input);
+
+    SubmitRequirementInfo fooSR =
+        gApi.projects().name(project.get()).submitRequirement("Foo").get();
+    assertThat(fooSR.description).isEqualTo(fooUpdate.description);
+    assertThat(fooSR.submittabilityExpression).isEqualTo(fooUpdate.submittabilityExpression);
+  }
+
+  @Test
+  public void cannotDeleteAndUpdateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    input.update = ImmutableMap.of("Foo", fooUpdate);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    gApi.projects().name(allProjects.get()).submitRequirements(new BatchSubmitRequirementInput());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update Submit Requirements");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.commitMessage = "Batch Update SubmitRequirements";
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.commitMessage = "Batch Update SubmitRequirements ";
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Batch Update SubmitRequirements");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b4731db..4760eb5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+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.LabelFunction;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
@@ -671,4 +673,16 @@
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Set NoOp function");
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setLabelRejected() {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java
new file mode 100644
index 0000000..3d762f8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.junit.Test;
+
+public class AccountLimitsIT extends AbstractDaemonTest {
+
+  @Inject private AccountLimits.Factory accountLimitsFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Provider<CurrentUser> currentUserProvider;
+
+  @Test
+  public void shouldIgnoreQueryLimitForInternalUser() throws Exception {
+    requestScopeOperations.setApiUserInternal();
+    AccountLimits objectUnderTest = accountLimitsFactory.create(currentUserProvider.get());
+
+    assertThat(objectUnderTest.getQueryLimit()).isEqualTo(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void shouldDefaultValueForReqularUser() {
+    AccountLimits objectUnderTest = accountLimitsFactory.create(currentUserProvider.get());
+
+    assertThat(objectUnderTest.getQueryLimit()).isEqualTo(GlobalCapability.DEFAULT_MAX_QUERY_LIMIT);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 2476f00..a696354 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -433,7 +433,7 @@
     Optional<AccountState> result =
         accountsUpdateProvider
             .get()
-            .update("Force set preferred email", id, (s, u) -> u.setPreferredEmail(email));
+            .update("Force set preferred email", id, u -> u.setPreferredEmail(email));
     assertThat(result.map(a -> a.account().preferredEmail())).hasValue(email);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index a6cdfa1..044beba 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.server.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
@@ -757,7 +758,7 @@
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.KEEP;
     reviewInput.message = "foo";
-    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "comment", false);
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "text", true);
     // Replace the existing draft.
     comment.id = draftInfo.id;
     reviewInput.comments = new HashMap<>();
@@ -767,6 +768,15 @@
     // DraftHandling.KEEP is ignored on publishing a comment.
     drafts = getDraftComments(changeId, revId);
     assertThat(drafts).isEmpty();
+
+    // Verify the comment
+    ImmutableList<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().get().values().stream()
+            .flatMap(l -> l.stream())
+            .collect(toImmutableList());
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).message).isEqualTo("text");
+    assertThat(comments.get(0).unresolved).isEqualTo(true);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 2ee5360..d88db52 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -2369,6 +2369,12 @@
 
   @Test
   public void revertChangeByOwner() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, sc.owner);
 
@@ -2394,6 +2400,12 @@
 
   @Test
   public void revertChangeByOwnerCcingSelf() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
 
@@ -2420,6 +2432,12 @@
 
   @Test
   public void revertChangeByOther() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, other);
 
@@ -2446,6 +2464,12 @@
 
   @Test
   public void revertChangeByOtherCcingSelf() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, other, CC_ON_OWN_COMMENTS);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
index 50fa3b2..3041744 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
@@ -64,6 +65,7 @@
 
   @Before
   public void setUp() throws Exception {
+    removeDefaultSubmitRequirements();
     TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
     configSubmitRequirement(
         project,
@@ -242,4 +244,8 @@
                 .count())
         .isEqualTo(1);
   }
+
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0c24b14..b5a3b66 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -75,6 +75,7 @@
 
   @Before
   public void setUp() throws Exception {
+    removeDefaultSubmitRequirements();
     PushOneCommit.Result pushResult =
         createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
     changeData = pushResult.getChange();
@@ -976,6 +977,10 @@
         .build();
   }
 
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
+
   /** Submit requirement predicate that always throws an error on match. */
   static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
       implements ChangeIsOperandFactory {
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
index 809cee9..9e9e1c2 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -34,109 +34,147 @@
 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.
+   * called, the call() method will block until another thread calls the complete() method or until
+   * a preset timeout is reached.
    */
-  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();
+  public static class LatchedMethod<T> {
+    private volatile T value;
 
     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 */
+    /** Assert that the 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.
+     * Assert that a timeout does not occur while awaiting the call() to be called. Fails if the
+     * waiting time elapses before the call() method is called, otherwise passes.
      */
-    public void assertAwait() {
+    public void assertCalledEventually() {
       assertThat(await(called)).isEqualTo(true);
     }
 
-    /** Unblock the Latch's call() method so that it can complete */
+    public T call() {
+      called.countDown();
+      await(complete);
+      return getValue();
+    }
+
+    public T call(T val) {
+      set(val);
+      return call();
+    }
+
+    public T callAndAwaitComplete() throws InterruptedException {
+      called.countDown();
+      complete.await();
+      return getValue();
+    }
+
     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;
-      }
+    public void set(T val) {
+      value = val;
+    }
+
+    public void complete(T val) {
+      set(val);
+      complete();
+    }
+
+    public void assertCalledEventuallyThenComplete(T val) {
+      assertCalledEventually();
+      complete(val);
+    }
+
+    protected T getValue() {
+      return value;
     }
   }
 
-  private static class LatchedRunnable implements Runnable {
-    public LatchedMethod run = new LatchedMethod();
+  public static class LatchedRunnable implements Runnable {
+    public LatchedMethod<?> run = new LatchedMethod<>();
+    public String name = "latched-runnable";
+
+    public LatchedRunnable(String name) {
+      this.name = name;
+    }
+
+    public LatchedRunnable() {}
 
     @Override
     public void run() {
-      run.latch.call();
+      run.call();
+    }
+
+    @Override
+    public String toString() {
+      return name;
     }
   }
 
-  private static class ForwardingListener implements TaskListener {
-    public volatile TaskListener delegate;
+  public static class ForwardingListener<T extends TaskListener> implements TaskListener {
+    public volatile T delegate;
     public volatile Task<?> task;
 
-    public void resetDelegate(TaskListener listener) {
+    public void resetDelegate(T 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);
-        }
+      if (isDelegatable(task)) {
+        delegate.onStart(task);
       }
     }
 
     @Override
     public void onStop(Task<?> task) {
+      if (isDelegatable(task)) {
+        delegate.onStop(task);
+      }
+    }
+
+    protected boolean isDelegatable(Task<?> task) {
       if (delegate != null) {
         if (this.task == task) {
-          delegate.onStop(task);
+          return true;
+        }
+        if (this.task == null) {
+          this.task = task;
+          return true;
         }
       }
+      return false;
     }
   }
 
-  private static class LatchedListener implements TaskListener {
-    public LatchedMethod onStart = new LatchedMethod();
-    public LatchedMethod onStop = new LatchedMethod();
+  public static class LatchedListener implements TaskListener {
+    public LatchedMethod<?> onStart = new LatchedMethod<>();
+    public LatchedMethod<?> onStop = new LatchedMethod<>();
 
     @Override
     public void onStart(Task<?> task) {
-      onStart.latch.call();
+      onStart.call();
     }
 
     @Override
     public void onStop(Task<?> task) {
-      onStop.latch.call();
+      onStop.call();
     }
   }
 
-  private static ForwardingListener forwarder;
+  private static final int AWAIT_TIMEOUT = 20;
+  private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+  private static final long MS_EMPTY_QUEUE =
+      TimeUnit.MILLISECONDS.convert(50, TimeUnit.MILLISECONDS);
+
+  private static ForwardingListener<TaskListener> forwarder;
 
   @Inject private WorkQueue workQueue;
   private ScheduledExecutorService executor;
@@ -149,9 +187,9 @@
     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 Manager") interference
-        forwarder = new ForwardingListener(); // Only gets bound once for all tests
+        // Forwarder.delegate is empty on start to protect test listener from non-test tasks (such
+        // as the "Log File Manager") interference
+        forwarder = new ForwardingListener<>(); // Only gets bound once for all tests
         bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
       }
     };
@@ -184,23 +222,23 @@
     int size = assertQueueBlockedOnExecution(runnable);
 
     // onStartThenRunThenOnStopAreCalled -> onStart...Called
-    listener.onStart.assertAwait();
+    listener.onStart.assertCalledEventually();
     assertQueueSize(size);
     runnable.run.assertUncalled();
     listener.onStop.assertUncalled();
 
     listener.onStart.complete();
     // onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
-    runnable.run.assertAwait();
+    runnable.run.assertCalledEventually();
     listener.onStop.assertUncalled();
 
     runnable.run.complete();
     // onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
-    listener.onStop.assertAwait();
+    listener.onStop.assertCalledEventually();
     assertQueueSize(size);
 
     listener.onStop.complete();
-    assertAwaitQueueSize(--size);
+    assertTaskCountIsEventually(--size);
   }
 
   @Test
@@ -208,7 +246,7 @@
     int size = assertQueueBlockedOnExecution(runnable);
 
     // firstBlocksSecond -> first...
-    listener.onStart.assertAwait();
+    listener.onStart.assertCalledEventually();
     assertQueueSize(size);
 
     LatchedRunnable runnable2 = new LatchedRunnable();
@@ -219,35 +257,35 @@
     assertQueueSize(size); // waiting on first
 
     listener.onStart.complete();
-    runnable.run.assertAwait();
+    runnable.run.assertCalledEventually();
     assertQueueSize(size); // waiting on first
     runnable2.run.assertUncalled();
 
     runnable.run.complete();
-    listener.onStop.assertAwait();
+    listener.onStop.assertCalledEventually();
     assertQueueSize(size); // waiting on first
     runnable2.run.assertUncalled();
 
     listener.onStop.complete();
-    runnable2.run.assertAwait();
+    runnable2.run.assertCalledEventually();
     assertQueueSize(--size);
 
     runnable2.run.complete();
-    assertAwaitQueueSize(--size);
+    assertTaskCountIsEventually(--size);
   }
 
   @Test
   public void states() throws Exception {
     executor.execute(runnable);
-    listener.onStart.assertAwait();
+    listener.onStart.assertCalledEventually();
     assertStateIs(Task.State.STARTING);
 
     listener.onStart.complete();
-    runnable.run.assertAwait();
+    runnable.run.assertCalledEventually();
     assertStateIs(Task.State.RUNNING);
 
     runnable.run.complete();
-    listener.onStop.assertAwait();
+    listener.onStop.assertCalledEventually();
     assertStateIs(Task.State.STOPPING);
 
     listener.onStop.complete();
@@ -255,8 +293,40 @@
     assertStateIs(Task.State.DONE);
   }
 
+  /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+  public static void assertTaskCountIsEventually(WorkQueue workQueue, int count)
+      throws InterruptedException {
+    long ms = 0;
+    while (count != workQueue.getTasks().size()) {
+      assertThat(ms++).isLessThan(MS_EMPTY_QUEUE);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+
+  public static void assertQueueSize(WorkQueue workQueue, int size) {
+    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+  }
+
+  @CanIgnoreReturnValue
+  public static boolean await(CountDownLatch latch) {
+    try {
+      return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+    } catch (InterruptedException e) {
+      return false;
+    }
+  }
+
+  public void assertTaskCountIsEventually(int count) throws InterruptedException {
+    TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+  }
+
+  public static void assertStateIs(Task<?> task, Task.State state) {
+    assertThat(task).isNotNull();
+    assertThat(task.getState()).isEqualTo(state);
+  }
+
   private void assertStateIs(Task.State state) {
-    assertThat(forwarder.task.getState()).isEqualTo(state);
+    assertStateIs(forwarder.task, state);
   }
 
   private int assertQueueBlockedOnExecution(Runnable runnable) {
@@ -267,19 +337,10 @@
   }
 
   private void assertQueueSize(int size) {
-    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+    assertQueueSize(workQueue, 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(100);
-      assertThat(i++).isLessThan(1000);
-    } while (size != workQueue.getTasks().size());
+    assertTaskCountIsEventually(0);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
new file mode 100644
index 0000000..3b82ebe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
@@ -0,0 +1,548 @@
+// 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.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedMethod;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedRunnable;
+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.Task.State;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.gerrit.server.git.WorkQueue.TaskParker;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskParkerIT extends AbstractDaemonTest {
+  private static class ForwardingParker extends TaskListenerIT.ForwardingListener<LatchedParker>
+      implements TaskParker {
+    public AtomicInteger isReadyToStartCounter = new AtomicInteger(0);
+    public AtomicInteger onNotReadyToStartCounter = new AtomicInteger(0);
+
+    @Override
+    public boolean isReadyToStart(Task<?> task) {
+      isReadyToStartCounter.incrementAndGet();
+      if (isDelegatable(task)) {
+        return delegate.isReadyToStart(task);
+      }
+      return true;
+    }
+
+    @Override
+    public void onNotReadyToStart(Task<?> task) {
+      onNotReadyToStartCounter.incrementAndGet();
+      if (isDelegatable(task)) {
+        delegate.onNotReadyToStart(task);
+      }
+    }
+
+    public void resetCounters() {
+      isReadyToStartCounter.getAndSet(0);
+      onNotReadyToStartCounter.getAndSet(0);
+    }
+  }
+
+  public static class LatchedParker extends TaskListenerIT.LatchedListener implements TaskParker {
+    private static final String EXPENSIVE_TASK = "expensive-task";
+    private final Semaphore expensiveTaskSemaphore = new Semaphore(1, true);
+    public volatile LatchedMethod<Boolean> isReadyToStart = new LatchedMethod<>();
+    public volatile LatchedMethod<?> onNotReadyToStart = new LatchedMethod<>();
+
+    @Override
+    public boolean isReadyToStart(Task<?> task) {
+      Boolean rtn = isReadyToStart.call();
+      if (EXPENSIVE_TASK.equals(task.toString()) && !expensiveTaskSemaphore.tryAcquire()) {
+        return false;
+      }
+      isReadyToStart = new LatchedMethod<>();
+      if (rtn != null) {
+        return rtn;
+      }
+      return true;
+    }
+
+    @Override
+    public void onNotReadyToStart(Task<?> task) {
+      onNotReadyToStart.call();
+      onNotReadyToStart = new LatchedMethod<>();
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      if (EXPENSIVE_TASK.equals(task.toString())) {
+        expensiveTaskSemaphore.release();
+      }
+      super.onStop(task);
+    }
+  }
+
+  public static class LatchedForeverRunnable extends LatchedRunnable {
+    public LatchedForeverRunnable(String name) {
+      super(name);
+    }
+
+    @Override
+    public void run() {
+      try {
+        run.callAndAwaitComplete();
+      } catch (InterruptedException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  private static ForwardingParker forwarder;
+  private static ForwardingParker forwarder2;
+  public static final long TIMEOUT = TimeUnit.MILLISECONDS.convert(200, TimeUnit.MILLISECONDS);
+
+  private final LatchedParker parker = new LatchedParker();
+
+  @Inject private WorkQueue workQueue;
+  private ScheduledExecutorService executor;
+
+  @Before
+  public void setupExecutorAndForwarder() throws InterruptedException {
+    executor = workQueue.createQueue(1, "TaskParkers");
+    // "Log File Manager"s are likely running and will interfere with tests
+    while (0 != workQueue.getTasks().size()) {
+      for (Task<?> t : workQueue.getTasks()) {
+        t.cancel(true);
+      }
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+    forwarder.delegate = parker;
+    forwarder.task = null;
+    forwarder.resetCounters();
+    forwarder2.delegate = null; // load only if test needs it
+    forwarder2.task = null;
+    forwarder2.resetCounters();
+  }
+
+  @After
+  public void shutdownExecutor() throws InterruptedException {
+    executor.shutdownNow();
+    executor.awaitTermination(1, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        // Forwarder.delegate is empty on start to protect test parker from non-test tasks (such as
+        // the "Log File Manager") interference
+        forwarder = new ForwardingParker(); // Only gets bound once for all tests
+        bind(TaskListener.class).annotatedWith(Exports.named("parker")).toInstance(forwarder);
+        forwarder2 = new ForwardingParker();
+        bind(TaskListener.class).annotatedWith(Exports.named("parker2")).toInstance(forwarder2);
+      }
+    };
+  }
+
+  @Test
+  public void noParkFlow() throws Exception {
+    LatchedRunnable runnable = new LatchedRunnable();
+
+    assertTaskCountIs(0);
+    assertThat(forwarder.task).isEqualTo(null);
+    parker.isReadyToStart.assertUncalled();
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    parker.onStop.assertUncalled();
+    assertCorePoolSizeIs(1);
+
+    executor.execute(runnable);
+    assertTaskCountIs(1);
+
+    parker.isReadyToStart.assertCalledEventually();
+    assertTaskCountIs(1);
+    assertStateIs(State.READY);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.isReadyToStart.complete();
+    parker.onStart.assertCalledEventually();
+    assertStateIs(State.STARTING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+    runnable.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.onStart.complete();
+    runnable.run.assertCalledEventually();
+    assertStateIs(State.RUNNING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    runnable.run.complete();
+    parker.onStop.assertCalledEventually();
+    assertStateIs(State.STOPPING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+
+    parker.onStop.complete();
+    assertTaskCountIsEventually(0);
+    assertStateIs(State.DONE);
+    parker.onNotReadyToStart.assertUncalled();
+    assertCorePoolSizeIs(1);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+  }
+
+  @Test
+  public void parkFirstSoSecondRuns() throws Exception {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    assertCorePoolSizeIs(1);
+
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventually();
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    executor.execute(runnable2);
+    assertTaskCountIs(2);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    // park runnable1
+    parker.isReadyToStart.complete(false);
+    assertCorePoolSizeIsEventually(2);
+    assertStateIs(task1, State.PARKED);
+
+    runnable2.run.assertCalledEventually();
+    assertTaskCountIs(2);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+    assertStateIs(task1, State.PARKED);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    runnable2.run.complete();
+
+    parker.isReadyToStart.assertCalledEventually();
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.isReadyToStart.complete(true);
+    parker.onStart.assertCalledEventually();
+    assertStateIs(task1, State.STARTING);
+    assertTaskCountIsEventually(1);
+    parker.onNotReadyToStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.onStart.complete();
+    runnable1.run.assertCalledEventually();
+    assertStateIs(task1, State.RUNNING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    runnable1.run.complete();
+    parker.onStop.assertCalledEventually();
+    assertStateIs(task1, State.STOPPING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+
+    parker.onStop.complete();
+    assertCorePoolSizeIsEventually(1);
+    assertTaskCountIsEventually(0);
+    assertStateIs(task1, State.DONE);
+    parker.onNotReadyToStart.assertUncalled();
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+  }
+
+  @Test
+  public void unParkPriorityOrder() throws Exception {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    LatchedRunnable runnable3 = new LatchedRunnable();
+
+    // park runnable1
+    assertCorePoolSizeIs(1);
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertStateIsEventually(task1, State.PARKED);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    assertTaskCountIsEventually(1);
+    assertCorePoolSizeIsEventually(2);
+
+    // park runnable2
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable2);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    Task<?> task2 = forwarder.task; // task for runnable2
+    assertStateIsEventually(task2, State.PARKED);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    assertTaskCountIsEventually(2);
+    assertCorePoolSizeIsEventually(3);
+
+    // set parker to ready and execute runnable3
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable3);
+
+    // assert runnable3 finishes executing and runnable1, runnable2 stay parked
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    parker.isReadyToStart.assertCalledEventually();
+    Task<?> task3 = forwarder.task; // task for runnable3
+    assertStateIs(task3, State.READY);
+    parker.isReadyToStart.complete(true);
+    parker.onStart.assertCalledEventually();
+    assertStateIs(task3, State.STARTING);
+    parker.onStart.complete();
+    runnable3.run.assertCalledEventually();
+    assertStateIs(task3, State.RUNNING);
+    runnable1.run.assertUncalled();
+    runnable2.run.assertUncalled();
+    runnable3.run.complete();
+    parker.onStop.assertCalledEventually();
+    assertStateIs(task3, State.STOPPING);
+    parker.onStop.complete();
+    assertTaskCountIsEventually(2);
+    assertStateIs(task3, State.DONE);
+
+    // assert runnable1 finishes executing and runnable2 stays parked
+    runnable1.run.assertCalledEventually();
+    assertStateIs(task1, State.RUNNING);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 4);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    runnable2.run.assertUncalled();
+    assertStateIs(task2, State.PARKED);
+    runnable1.run.complete();
+    assertCorePoolSizeIsEventually(2);
+    assertTaskCountIsEventually(1);
+    assertStateIs(task1, State.DONE);
+
+    // assert runnable2 finishes executing
+    runnable2.run.assertCalledEventually();
+    assertStateIs(task2, State.RUNNING);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 5);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    runnable2.run.complete();
+    assertCorePoolSizeIsEventually(1);
+    assertTaskCountIsEventually(0);
+    assertStateIs(task2, State.DONE);
+  }
+
+  @Test
+  public void notReadyToStartIsCalledOnReadyListenerWhenAnotherListenerIsNotReady()
+      throws InterruptedException {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    LatchedRunnable runnable2 = new LatchedRunnable();
+
+    LatchedParker parker2 = new LatchedParker();
+    forwarder2.delegate = parker2;
+
+    // park runnable1 (parker1 is ready and parker2 is not ready)
+    assertCorePoolSizeIs(1);
+    executor.execute(runnable1);
+    parker2.isReadyToStart.complete(false);
+
+    assertTaskCountIsEventually(1);
+    assertCorePoolSizeIsEventually(2);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder2.isReadyToStartCounter, 1);
+    assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertStateIsEventually(task1, State.PARKED);
+
+    // set parker2 to ready and execute runnable-2
+    parker2.isReadyToStart.set(true);
+    forwarder.resetDelegate(parker);
+    forwarder2.resetDelegate(parker2);
+    executor.execute(runnable2);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+    assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder2.isReadyToStartCounter, 2);
+    assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+    Task<?> task2 = forwarder.task; // task for runnable2
+
+    assertCorePoolSizeIsEventually(1);
+    runnable2.run.assertCalledEventually();
+    runnable2.run.complete();
+    assertTaskCountIsEventually(1);
+    assertStateIs(task2, State.DONE);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+    assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder2.isReadyToStartCounter, 3);
+    assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+
+    runnable1.run.assertCalledEventually();
+    runnable1.run.complete();
+    assertTaskCountIsEventually(0);
+    assertStateIs(task1, State.DONE);
+  }
+
+  @Test
+  public void runFirstParkSecondUsingTaskName() throws InterruptedException {
+    LatchedForeverRunnable runnable1 = new LatchedForeverRunnable("expensive-task");
+    LatchedRunnable runnable2 = new LatchedRunnable("expensive-task");
+    LatchedParker parker = new LatchedParker();
+    executor = workQueue.createQueue(2, "TaskParkers");
+    assertCorePoolSizeIs(2);
+
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable1);
+    parker.isReadyToStart.complete();
+    parker.onStart.complete();
+    runnable1.run.assertCalledEventually();
+    assertTaskCountIsEventually(1);
+    assertCorePoolSizeIs(2);
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertStateIs(task1, State.RUNNING);
+
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable2);
+    parker.isReadyToStart.assertCalledEventually();
+    assertCorePoolSizeIsEventually(3);
+    Task<?> task2 = forwarder.task; // task for runnable2
+    assertStateIs(task2, State.PARKED);
+
+    forwarder.resetDelegate(parker);
+    runnable1.run.complete(); // unblock runnable1
+
+    assertCorePoolSizeIsEventually(2);
+    assertTaskCountIsEventually(0); // assert both tasks finish
+  }
+
+  @Test
+  public void interruptingParkedTaskDecrementsCorePoolSize() throws InterruptedException {
+    String taskName = "to-be-parked";
+    LatchedRunnable runnable1 = new LatchedRunnable(taskName);
+    assertCorePoolSizeIs(1);
+
+    // park runnable1
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    assertCorePoolSizeIsEventually(2);
+    assertStateIsEventually(forwarder.task, State.PARKED);
+
+    // interrupt the thread with parked task
+    for (Thread t : Thread.getAllStackTraces().keySet()) {
+      if (t.getName().contains(taskName)) {
+        t.interrupt();
+        break;
+      }
+    }
+
+    assertCorePoolSizeIsEventually(1);
+  }
+
+  @Test
+  public void canCancelParkedTask() throws InterruptedException {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    assertCorePoolSizeIs(1);
+
+    // park runnable1
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    assertCorePoolSizeIsEventually(2);
+    Task<?> task = forwarder.task;
+    assertStateIsEventually(task, State.PARKED);
+
+    // cancel parked task
+    task.cancel(true);
+
+    // assert core pool size is reduced and task is cancelled
+    assertCorePoolSizeIsEventually(1);
+    assertTaskCountIsEventually(0);
+    assertStateIs(State.CANCELLED);
+  }
+
+  private void assertTaskCountIs(int size) {
+    TaskListenerIT.assertQueueSize(workQueue, size);
+  }
+
+  private void assertTaskCountIsEventually(int count) throws InterruptedException {
+    TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+  }
+
+  private void assertCorePoolSizeIs(int count) {
+    assertThat(count).isEqualTo(((ScheduledThreadPoolExecutor) executor).getCorePoolSize());
+  }
+
+  private void assertCorePoolSizeIsEventually(int count) throws InterruptedException {
+    ScheduledThreadPoolExecutor scheduledThreadPoolExecutor =
+        (ScheduledThreadPoolExecutor) executor;
+    long ms = 0;
+    while (count != scheduledThreadPoolExecutor.getCorePoolSize()) {
+      assertThat(ms++).isLessThan(TIMEOUT);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+
+  private void assertCounter(AtomicInteger counter, int desiredCount) {
+    assertThat(counter.get()).isEqualTo(desiredCount);
+  }
+
+  private void assertCounterIsEventually(AtomicInteger counter, int desiredCount)
+      throws InterruptedException {
+    long ms = 0;
+    while (desiredCount != counter.get()) {
+      assertThat(ms++).isLessThan(TIMEOUT);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+
+  private void assertStateIs(Task.State state) {
+    TaskListenerIT.assertStateIs(forwarder.task, state);
+  }
+
+  private void assertStateIs(Task<?> task, Task.State state) {
+    TaskListenerIT.assertStateIs(task, state);
+  }
+
+  private void assertStateIsEventually(Task<?> task, Task.State state) throws InterruptedException {
+    long ms = 0;
+    assertThat(task).isNotNull();
+    while (!task.getState().equals(state)) {
+      assertThat(ms++).isLessThan(TIMEOUT);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
index 6c8c85a..47d0d9a 100644
--- a/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
@@ -36,10 +36,14 @@
   public void allValuesConvertedToProto() {
     ApplyPatchInput applyPatchInput = new ApplyPatchInput();
     applyPatchInput.patch = "test-patch";
+    applyPatchInput.allowConflicts = true;
     Entities.ApplyPatchInput proto = applyPatchInputProtoConverter.toProto(applyPatchInput);
 
     Entities.ApplyPatchInput expectedProto =
-        Entities.ApplyPatchInput.newBuilder().setPatch("test-patch").build();
+        Entities.ApplyPatchInput.newBuilder()
+            .setPatch("test-patch")
+            .setAllowConflicts(true)
+            .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
@@ -47,18 +51,24 @@
   public void allValuesConvertedToProtoAndBackAgain() {
     ApplyPatchInput applyPatchInput = new ApplyPatchInput();
     applyPatchInput.patch = "test-patch";
+    applyPatchInput.allowConflicts = true;
 
     ApplyPatchInput convertedApplyPatchInput =
         applyPatchInputProtoConverter.fromProto(
             applyPatchInputProtoConverter.toProto(applyPatchInput));
 
     assertThat(Objects.equals(applyPatchInput.patch, convertedApplyPatchInput.patch)).isTrue();
+    assertThat(applyPatchInput.allowConflicts).isEqualTo(convertedApplyPatchInput.allowConflicts);
   }
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
     assertThatSerializedClass(ApplyPatchInput.class)
-        .hasFields(ImmutableMap.<String, Type>builder().put("patch", String.class).build());
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("patch", String.class)
+                .put("allowConflicts", Boolean.class)
+                .build());
   }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 0ca9478..804397b 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -4,16 +4,19 @@
     name = "proto_converter_tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//lib:guava",
+        "//lib:guava-testlib",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
         "//proto:entities_java_proto",
+        "@commons-lang3//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
new file mode 100644
index 0000000..eb69d53
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
@@ -0,0 +1,286 @@
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Primitives;
+import com.google.common.reflect.ClassPath;
+import com.google.common.reflect.ClassPath.ClassInfo;
+import com.google.common.testing.ArbitraryInstances;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Message;
+import com.google.protobuf.MessageLite;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  SafeProtoConverterTest.ListSafeProtoConverterTest.class, //
+  SafeProtoConverterTest.PerTypeSafeProtoConverterTest.class, //
+})
+public class SafeProtoConverterTest {
+  public static class ListSafeProtoConverterTest {
+    @Test
+    public void areAllConvertersEnums() throws Exception {
+      Stream<? extends Class<?>> safeConverters =
+          ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+              .filter(type -> type.getPackageName().contains("gerrit"))
+              .map(ClassInfo::load)
+              .filter(SafeProtoConverter.class::isAssignableFrom)
+              .filter(clz -> !SafeProtoConverter.class.equals(clz));
+      // Safe converters must be enums. See also `isConverterAValidEnum` test below.
+      assertThat(safeConverters.allMatch(Class::isEnum)).isTrue();
+    }
+  }
+
+  @RunWith(Parameterized.class)
+  public static class PerTypeSafeProtoConverterTest {
+    @Parameter(0)
+    public SafeProtoConverter<Message, Object> converter;
+
+    @Parameter(1)
+    public String converterName;
+
+    @Parameters(name = "PerTypeSafeProtoConverterTest${1}")
+    public static ImmutableList<Object[]> listSafeConverters() throws Exception {
+      return ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+          .filter(type -> type.getPackageName().contains("gerrit"))
+          .map(ClassInfo::load)
+          .filter(SafeProtoConverter.class::isAssignableFrom)
+          .filter(clz -> !SafeProtoConverter.class.equals(clz))
+          .filter(Class::isEnum)
+          .map(clz -> (SafeProtoConverter<Message, Object>) clz.getEnumConstants()[0])
+          .map(clz -> new Object[] {clz, clz.getClass().getSimpleName()})
+          .collect(toImmutableList());
+    }
+
+    /**
+     * For rising visibility, all Java Entity classes which have a {@link SafeProtoConverter}, must
+     * be annotated with {@link ConvertibleToProto}.
+     */
+    @Test
+    public void isJavaClassMarkedAsConvertibleToProto() {
+      assertThat(converter.getEntityClass().getDeclaredAnnotation(ConvertibleToProto.class))
+          .isNotNull();
+    }
+
+    /**
+     * All {@link SafeProtoConverter} implementations must be enums with a single instance. Please
+     * prefer descriptive enum and instance names, such as {@code
+     * MyTypeConverter::MY_TYPE_CONVERTER}.
+     */
+    @Test
+    public void isConverterAValidEnum() {
+      assertThat(converter.getClass().isEnum()).isTrue();
+      assertThat(converter.getClass().getEnumConstants().length).isEqualTo(1);
+    }
+
+    /**
+     * If this test fails, it's likely that you added a field to a Java class that has a {@link
+     * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+     * update the corresponding proto accordingly.
+     */
+    @Test
+    public void javaDefaultsKeptOnDoubleConversion() {
+      Object orig;
+      try {
+        orig = buildObjectWithFullFieldsOrThrow(converter.getEntityClass());
+      } catch (Exception e) {
+        throw new IllegalStateException(
+            String.format(
+                "Failed to build object for type %s, this likely means the buildObjectWithFullFieldsOrThrow should be adapted.",
+                converter.getEntityClass().getName()),
+            e);
+      }
+      Object res = converter.fromProto(converter.toProto(converter.getEntityClass().cast(orig)));
+      assertThat(orig).isEqualTo(res);
+      // If this assertion fails, it's likely that you forgot to update the `equals` method to
+      // include your new field.
+      assertThat(EqualsBuilder.reflectionEquals(orig, res)).isTrue();
+    }
+
+    /**
+     * If this test fails, it's likely that you added a field to a proto that has a {@link
+     * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+     * update the corresponding Java class accordingly.
+     */
+    @Test
+    public void protoDefaultsKeptOnDoubleConversion() {
+      Message defaultInstance = getProtoDefaultInstance(converter.getProtoClass());
+      Message preFilled = explicitlyFillProtoDefaults(defaultInstance);
+      Message resFromDefault =
+          converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+      Message resFromPrefilled =
+          converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+      assertThat(resFromDefault).isEqualTo(preFilled);
+      assertThat(resFromPrefilled).isEqualTo(preFilled);
+    }
+
+    @Nullable
+    private static Object buildObjectWithFullFieldsOrThrow(Class<?> clz) throws Exception {
+      if (clz == null) {
+        return null;
+      }
+      Object obj = construct(clz);
+      if (isSimple(clz)) {
+        return obj;
+      }
+      for (Field field : clz.getDeclaredFields()) {
+        if (Modifier.isStatic(field.getModifiers())) {
+          continue;
+        }
+        Class<?> parameterizedType = getParameterizedType(field);
+        if (!field.getType().isArray()
+            && !Map.class.isAssignableFrom(field.getType())
+            && !Collection.class.isAssignableFrom(field.getType())) {
+          if (!field.trySetAccessible()) {
+            return null;
+          }
+          field.set(obj, buildObjectWithFullFieldsOrThrow(field.getType()));
+        } else if (Collection.class.isAssignableFrom(field.getType())
+            && parameterizedType != null) {
+          field.set(obj, ImmutableList.of(buildObjectWithFullFieldsOrThrow(parameterizedType)));
+        }
+      }
+      return obj;
+    }
+
+    /**
+     * AutoValue annotations are not retained on runtime. We can only find out if a class is an
+     * AutoValue, by trying to load the expected AutoValue class.
+     *
+     * <p>For the class {@code package.Clz}, the AutoValue class name is {@code
+     * package.AutoValue_Clz}, for {@code package.Enclosing$Clz}, it is {@code
+     * package.AutoValue_Enclosing_Clz}
+     */
+    static Optional<Class<?>> toRepresentingAutoValueClass(Class<?> clz) {
+      String origClzName = clz.getName();
+      String autoValueClzName =
+          origClzName.substring(0, origClzName.lastIndexOf("."))
+              + ".AutoValue_"
+              + origClzName.substring(origClzName.lastIndexOf(".") + 1);
+      autoValueClzName = autoValueClzName.replace('$', '_');
+      try {
+        return Optional.of(clz.getClassLoader().loadClass(autoValueClzName));
+      } catch (Exception e) {
+        return Optional.empty();
+      }
+    }
+
+    @Nullable
+    private static Class<?> getParameterizedType(Field field) {
+      if (!Collection.class.isAssignableFrom(field.getType())) {
+        return null;
+      }
+      Type genericType = field.getGenericType();
+      if (genericType instanceof ParameterizedType) {
+        return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+      }
+      return null;
+    }
+
+    @Nonnull
+    static Object construct(@Nonnull Class<?> clz) {
+      try {
+        Object arbitrary = ArbitraryInstances.get(clz);
+        if (arbitrary != null) {
+          return arbitrary;
+        }
+        Optional<Class<?>> optionalAutoValueRepresentation = toRepresentingAutoValueClass(clz);
+        if (optionalAutoValueRepresentation.isPresent()) {
+          return construct(optionalAutoValueRepresentation.get());
+        }
+        Constructor<?> constructor =
+            Arrays.stream(clz.getDeclaredConstructors())
+                // Filter out copy constructors
+                .filter(
+                    c ->
+                        c.getParameterCount() != 1
+                            || !c.getParameterTypes()[0].isAssignableFrom(clz))
+                // Filter out private constructors which cannot be set accessible.
+                .filter(c -> c.canAccess(null) || c.trySetAccessible())
+                .min(Comparator.comparingInt(Constructor::getParameterCount))
+                .get();
+        List<Object> args = new ArrayList<>();
+        for (Class<?> f : constructor.getParameterTypes()) {
+          args.add(construct(f));
+        }
+        return constructor.newInstance(args.toArray());
+      } catch (Exception e) {
+        throw new IllegalStateException("Failed to construct class " + clz.getName(), e);
+      }
+    }
+
+    static boolean isSimple(Class<?> c) {
+      return c.isPrimitive()
+          || c.isEnum()
+          || Primitives.isWrapperType(c)
+          || String.class.isAssignableFrom(c)
+          || Timestamp.class.isAssignableFrom(c);
+    }
+
+    /**
+     * Returns the default instance for the given MessageLite class, if it has the {@code
+     * getDefaultInstance} static method.
+     *
+     * @param type the protobuf message class
+     * @throws IllegalArgumentException if the given class doesn't have the static {@code
+     *     getDefaultInstance} method
+     */
+    public static <T extends MessageLite> T getProtoDefaultInstance(Class<T> type) {
+      try {
+        return type.cast(type.getMethod("getDefaultInstance").invoke(null));
+      } catch (ReflectiveOperationException | ClassCastException e) {
+        throw new IllegalStateException("Cannot get default instance for " + type, e);
+      }
+    }
+
+    private static Message explicitlyFillProtoDefaults(Message defaultInstance) {
+      Message.Builder res = defaultInstance.toBuilder();
+      for (FieldDescriptor f : defaultInstance.getDescriptorForType().getFields()) {
+        try {
+          if (f.getType().equals(FieldDescriptor.Type.MESSAGE)) {
+            if (f.isRepeated()) {
+              res.addRepeatedField(
+                  f,
+                  explicitlyFillProtoDefaults(
+                      explicitlyFillProtoDefaults(
+                          getProtoDefaultInstance(res.newBuilderForField(f).build().getClass()))));
+            } else {
+              res.setField(f, explicitlyFillProtoDefaults((Message) defaultInstance.getField(f)));
+            }
+          } else {
+            res.setField(f, defaultInstance.getField(f));
+          }
+        } catch (Exception e) {
+          throw new IllegalStateException("Failed to fill default instance for " + f.getName(), e);
+        }
+      }
+      return res.build();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
index 8cfcdbd..9888670 100644
--- a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -18,6 +18,9 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter.DIFF_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter.EDIT_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter.GENERAL_PREFERENCES_INFO_CONVERTER;
 import static java.util.Arrays.stream;
 
 import com.google.common.collect.ImmutableList;
@@ -35,9 +38,6 @@
 import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.MenuItem;
 import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.Theme;
 import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.TimeFormat;
-import com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter;
-import com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter;
-import com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter;
 import com.google.protobuf.Descriptors.Descriptor;
 import com.google.protobuf.Descriptors.EnumDescriptor;
 import java.util.EnumSet;
@@ -70,6 +70,35 @@
     }
   }
 
+  /**
+   * If this test fails, it's likely that you added a field to {@link GeneralPreferencesInfo}, or
+   * that you have changed the default value for such a field. Please update the {@link
+   * UserPreferences.GeneralPreferencesInfo} proto accordingly.
+   */
+  @Test
+  public void generalPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+    GeneralPreferencesInfo orig = GeneralPreferencesInfo.defaults();
+    GeneralPreferencesInfo res =
+        GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(
+            GENERAL_PREFERENCES_INFO_CONVERTER.toProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
+  /**
+   * If this test fails, it's likely that you added a field to {@link
+   * UserPreferences.GeneralPreferencesInfo}, or that you have changed the default value for such a
+   * field. Please update the {@link GeneralPreferencesInfo} class accordingly.
+   */
+  @Test
+  public void generalPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+    UserPreferences.GeneralPreferencesInfo orig =
+        UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
+    UserPreferences.GeneralPreferencesInfo res =
+        GENERAL_PREFERENCES_INFO_CONVERTER.toProto(
+            GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
   @Test
   public void generalPreferencesInfo_doubleConversionWithAllFieldsSet() {
     UserPreferences.GeneralPreferencesInfo originalProto =
@@ -112,8 +141,8 @@
             .setDiffPageSidebar("plugin-insight")
             .build();
     UserPreferences.GeneralPreferencesInfo resProto =
-        GeneralPreferencesInfoConverter.toProto(
-            GeneralPreferencesInfoConverter.fromProto(originalProto));
+        GENERAL_PREFERENCES_INFO_CONVERTER.toProto(
+            GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
     assertThat(resProto).isEqualTo(originalProto);
   }
 
@@ -125,7 +154,8 @@
             new com.google.gerrit.extensions.client.MenuItem(
                 " name1 ", " url1 ", " target1 ", " id1 "),
             new com.google.gerrit.extensions.client.MenuItem(null, " url2 ", null, null));
-    UserPreferences.GeneralPreferencesInfo resProto = GeneralPreferencesInfoConverter.toProto(info);
+    UserPreferences.GeneralPreferencesInfo resProto =
+        GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(resProto)
         .isEqualTo(
             UserPreferences.GeneralPreferencesInfo.newBuilder()
@@ -155,7 +185,7 @@
                         .build(),
                     MenuItem.newBuilder().setUrl(" url2 ").build()))
             .build();
-    GeneralPreferencesInfo info = GeneralPreferencesInfoConverter.fromProto(originalProto);
+    GeneralPreferencesInfo info = GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(originalProto);
     assertThat(info.my)
         .containsExactly(
             new com.google.gerrit.extensions.client.MenuItem("name1", "url1", "target1", "id1"),
@@ -165,14 +195,14 @@
   @Test
   public void generalPreferencesInfo_emptyJavaToProto() {
     GeneralPreferencesInfo info = new GeneralPreferencesInfo();
-    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    UserPreferences.GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res).isEqualToDefaultInstance();
   }
 
   @Test
   public void generalPreferencesInfo_defaultJavaToProto() {
     GeneralPreferencesInfo info = GeneralPreferencesInfo.defaults();
-    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    UserPreferences.GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res)
         .ignoringFieldAbsence()
         .isEqualTo(UserPreferences.GeneralPreferencesInfo.getDefaultInstance());
@@ -182,7 +212,7 @@
   public void generalPreferencesInfo_emptyProtoToJava() {
     UserPreferences.GeneralPreferencesInfo proto =
         UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
-    GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.fromProto(proto);
+    GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(proto);
     assertThat(res).isEqualTo(new GeneralPreferencesInfo());
   }
 
@@ -208,6 +238,33 @@
     }
   }
 
+  /**
+   * If this test fails, it's likely that you added a field to {@link DiffPreferencesInfo}, or that
+   * you have changed the default value for such a field. Please update the {@link
+   * UserPreferences.DiffPreferencesInfo} proto accordingly.
+   */
+  @Test
+  public void diffPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+    DiffPreferencesInfo orig = DiffPreferencesInfo.defaults();
+    DiffPreferencesInfo res =
+        DIFF_PREFERENCES_INFO_CONVERTER.fromProto(DIFF_PREFERENCES_INFO_CONVERTER.toProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
+  /**
+   * If this test fails, it's likely that you added a field to {@link
+   * UserPreferences.DiffPreferencesInfo}, or that you have changed the default value for such a
+   * field. Please update the {@link DiffPreferencesInfo} class accordingly.
+   */
+  @Test
+  public void diffPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+    UserPreferences.DiffPreferencesInfo orig =
+        UserPreferences.DiffPreferencesInfo.getDefaultInstance();
+    UserPreferences.DiffPreferencesInfo res =
+        DIFF_PREFERENCES_INFO_CONVERTER.toProto(DIFF_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
   @Test
   public void diffPreferencesInfo_doubleConversionWithAllFieldsSet() {
     UserPreferences.DiffPreferencesInfo originalProto =
@@ -238,21 +295,22 @@
             .setSkipUncommented(false)
             .build();
     UserPreferences.DiffPreferencesInfo resProto =
-        DiffPreferencesInfoConverter.toProto(DiffPreferencesInfoConverter.fromProto(originalProto));
+        DIFF_PREFERENCES_INFO_CONVERTER.toProto(
+            DIFF_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
     assertThat(resProto).isEqualTo(originalProto);
   }
 
   @Test
   public void diffPreferencesInfo_emptyJavaToProto() {
     DiffPreferencesInfo info = new DiffPreferencesInfo();
-    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    UserPreferences.DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res).isEqualToDefaultInstance();
   }
 
   @Test
   public void diffPreferencesInfo_defaultJavaToProto() {
     DiffPreferencesInfo info = DiffPreferencesInfo.defaults();
-    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    UserPreferences.DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res)
         .ignoringFieldAbsence()
         .isEqualTo(UserPreferences.DiffPreferencesInfo.getDefaultInstance());
@@ -262,7 +320,7 @@
   public void diffPreferencesInfo_emptyProtoToJava() {
     UserPreferences.DiffPreferencesInfo proto =
         UserPreferences.DiffPreferencesInfo.getDefaultInstance();
-    DiffPreferencesInfo res = DiffPreferencesInfoConverter.fromProto(proto);
+    DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.fromProto(proto);
     assertThat(res).isEqualTo(new DiffPreferencesInfo());
   }
 
@@ -288,6 +346,33 @@
     }
   }
 
+  /**
+   * If this test fails, it's likely that you added a field to {@link EditPreferencesInfo}, or that
+   * you have changed the default value for such a field. Please update the {@link
+   * UserPreferences.EditPreferencesInfo} proto accordingly.
+   */
+  @Test
+  public void editPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+    EditPreferencesInfo orig = EditPreferencesInfo.defaults();
+    EditPreferencesInfo res =
+        EDIT_PREFERENCES_INFO_CONVERTER.fromProto(EDIT_PREFERENCES_INFO_CONVERTER.toProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
+  /**
+   * If this test fails, it's likely that you added a field to {@link
+   * UserPreferences.EditPreferencesInfo}, or that you have changed the default value for such a
+   * field. Please update the {@link EditPreferencesInfo} class accordingly.
+   */
+  @Test
+  public void editPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+    UserPreferences.EditPreferencesInfo orig =
+        UserPreferences.EditPreferencesInfo.getDefaultInstance();
+    UserPreferences.EditPreferencesInfo res =
+        EDIT_PREFERENCES_INFO_CONVERTER.toProto(EDIT_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
   @Test
   public void editPreferencesInfo_doubleConversionWithAllFieldsSet() {
     UserPreferences.EditPreferencesInfo originalProto =
@@ -308,21 +393,22 @@
             .setShowBase(false)
             .build();
     UserPreferences.EditPreferencesInfo resProto =
-        EditPreferencesInfoConverter.toProto(EditPreferencesInfoConverter.fromProto(originalProto));
+        EDIT_PREFERENCES_INFO_CONVERTER.toProto(
+            EDIT_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
     assertThat(resProto).isEqualTo(originalProto);
   }
 
   @Test
   public void editPreferencesInfo_emptyJavaToProto() {
     EditPreferencesInfo info = new EditPreferencesInfo();
-    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    UserPreferences.EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res).isEqualToDefaultInstance();
   }
 
   @Test
   public void editPreferencesInfo_defaultJavaToProto() {
     EditPreferencesInfo info = EditPreferencesInfo.defaults();
-    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    UserPreferences.EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res)
         .ignoringFieldAbsence()
         .isEqualTo(UserPreferences.EditPreferencesInfo.getDefaultInstance());
@@ -332,7 +418,7 @@
   public void editPreferencesInfo_emptyProtoToJava() {
     UserPreferences.EditPreferencesInfo proto =
         UserPreferences.EditPreferencesInfo.getDefaultInstance();
-    EditPreferencesInfo res = EditPreferencesInfoConverter.fromProto(proto);
+    EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.fromProto(proto);
     assertThat(res).isEqualTo(new EditPreferencesInfo());
   }
 
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
index bf224f0..01bf941 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -52,14 +53,15 @@
   public void internalQueriesDoNotPaginateWithNonePaginationType() throws Exception {
     assumeTrue(PaginationType.NONE == getCurrentPaginationType());
 
-    final int GROUPS_CREATED_SIZE = 2;
-    List<GroupInfo> groupsCreated = new ArrayList<>();
-    for (int i = 0; i < GROUPS_CREATED_SIZE; i++) {
-      groupsCreated.add(createGroupThatIsVisibleToAll(name("group-" + i)));
+    List<GroupInfo> groupsVisibleToAll = new ArrayList<>();
+    groupsVisibleToAll.add(gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).get());
+
+    for (int i = 0; i < 2; i++) {
+      groupsVisibleToAll.add(createGroupThatIsVisibleToAll(name("group-" + i)));
     }
 
-    List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsCreated);
-    assertThat(result.size()).isEqualTo(GROUPS_CREATED_SIZE);
+    List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsVisibleToAll);
+    assertThat(result.size()).isEqualTo(groupsVisibleToAll.size());
     assertThat(result.get(result.size() - 1)._moreGroups).isNull();
     assertThatSearchQueryWasNotPaginated();
   }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 572e7af..d8339e7 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -239,13 +240,14 @@
 
   @Test
   public void byIsVisibleToAll() throws Exception {
-    assertQuery("is:visibletoall");
+    GroupInfo serviceUsersGroupInfo = gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).get();
+    assertQuery("is:visibletoall", serviceUsersGroupInfo);
 
     GroupInfo groupThatIsVisibleToAll =
         createGroupThatIsVisibleToAll(name("group-that-is-visible-to-all"));
     createGroup(name("group"));
 
-    assertQuery("is:visibletoall", groupThatIsVisibleToAll);
+    assertQuery("is:visibletoall", groupThatIsVisibleToAll, serviceUsersGroupInfo);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 6c79c43..089ceea 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultSubmitRequirements;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
@@ -89,11 +90,13 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
-            .serviceUsersGroup(batchUsersGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -139,6 +142,7 @@
             .addBooleanProjectConfig(
                 BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
             .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -158,6 +162,26 @@
   }
 
   @Test
+  public void createAllProjectsWithoutInitializingDefaultSubmitRequirements() throws Exception {
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder()
+            .administratorsGroup(adminsGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
+            .initDefaultSubmitRequirements(false)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getAllProjectsWithoutDefaultSubmitRequirements());
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  @Test
   public void createAllProjectsOnlyInitializingProjectDescription() throws Exception {
     String description = "a project.config with just a project description";
     AllProjectsInput allProjectsInput =
@@ -165,6 +189,7 @@
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
             .projectDescription(description)
             .initDefaultAcls(false)
+            .initDefaultSubmitRequirements(false)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
diff --git a/lib/BUILD b/lib/BUILD
index f9ece52..a0debc1 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -92,10 +92,7 @@
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
-    exports = [
-        "@com_google_protobuf//:protobuf_java",
-        "@com_google_protobuf//:protobuf_javalite",
-    ],
+    exports = ["@protobuf-java//jar"],
 )
 
 java_library(
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index 4105d85..d55a273 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -12,6 +12,7 @@
     srcs = [
         "@ui_npm//highlight.js",
         "@ui_npm//highlightjs-closure-templates",
+        "@ui_npm//highlightjs-epp",
         "@ui_npm//highlightjs-structured-text",
     ],
     config_file = "rollup.config.js",
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js
index c2d048d..811dec7 100644
--- a/lib/highlightjs/index.js
+++ b/lib/highlightjs/index.js
@@ -17,9 +17,11 @@
 
 import hljs from 'highlight.js';
 import soy from 'highlightjs-closure-templates';
+import epp from 'highlightjs-epp';
 import iecst from 'highlightjs-structured-text';
 
 hljs.registerLanguage('soy', soy);
+hljs.registerLanguage('epp', epp);
 hljs.registerLanguage('iecst', iecst);
 
 export default hljs;
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 6865340..11be929 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -11,6 +11,11 @@
 grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
 
 cat << EOF > $TMP/want
+auto-common
+auto-factory
+auto-service-annotations
+auto-value
+auto-value-annotations
 cglib-3_2
 commons-io
 dropwizard-core
@@ -20,6 +25,7 @@
 flogger-google-extensions
 flogger-log4j-backend
 flogger-system-backend
+gson
 guava
 guava-testlib
 guice-assistedinject
@@ -43,6 +49,7 @@
 nekohtml
 objenesis
 openid-consumer
+protobuf-java
 soy
 sshd-mina
 sshd-osgi
diff --git a/modules/jgit b/modules/jgit
index c0b415f..76ce6d9 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit c0b415fb028b4c1f29b6df749323bbb11599495d
+Subproject commit 76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0
diff --git a/package.json b/package.json
index 72e75b7..82d3854 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,8 @@
     "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",
-    "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app"
+    "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app",
+    "gjf": "./tools/run_gjf.sh"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/hooks b/plugins/hooks
index f975f91..41c3ad1 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit f975f914312b258f84957d19f96014c3edd12644
+Subproject commit 41c3ad1d584f3b81bacc59e89e022dc1e7ffc3f2
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index cdd2d2d..53d615a 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit cdd2d2d69666a70a16ac02bacf8e7fbbf4ca9979
+Subproject commit 53d615a6286d1ca1fdca7dc84eee42b4e63fc70d
diff --git a/plugins/replication b/plugins/replication
index 56b8ffb..aacb8b2 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 56b8ffbab5bf619c0b6b5d44f0255fd41b9e1c89
+Subproject commit aacb8b2a20267e88ccda811d27293bac66e2006b
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index af481fd..d6425be 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -31,3 +31,40 @@
     };
   }
 }
+
+/** <gr-textarea> input event */
+export declare interface InputEventDetail {
+  value: string;
+}
+
+/** <gr-textarea> event for current cursor position */
+export declare interface CursorPositionChangeEventDetail {
+  position: number;
+}
+
+/** <gr-textarea> event when showing a hint */
+export declare interface HintShownEventDetail {
+  hint: string;
+  oldValue: string;
+}
+
+/** <gr-textarea> event when a hint was dismissed */
+export declare interface HintDismissedEventDetail {
+  hint: string;
+}
+
+/** <gr-textarea> event when a hint was applied */
+export declare interface HintAppliedEventDetail {
+  hint: string;
+  oldValue: string;
+}
+
+/** <gr-textarea> interface that external users can rely on */
+export declare interface GrTextarea extends HTMLElement {
+  value?: string;
+  nativeElement?: HTMLElement;
+  placeholder?: string;
+  placeholderHint?: string;
+  hint?: string;
+  setRangeText: (replacement: string, start: number, end: number) => void;
+}
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 997b8fe8..134cdd3 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -395,6 +395,7 @@
   pending_reviewers?: AccountInfo[];
   reviewer_updates?: ReviewerUpdateInfo[];
   messages?: ChangeMessageInfo[];
+  current_revision_number: PatchSetNumber;
   current_revision?: CommitId;
   revisions?: {[revisionId: string]: RevisionInfo};
   tracking_ids?: TrackingIdInfo[];
@@ -1319,4 +1320,5 @@
   fix_id: FixId;
   description: string;
   replacements: FixReplacementInfo[];
+  log_probability?: number;
 }
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index b4158be..1aa4ebe 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -27,7 +27,21 @@
   lineNumber?: number;
 }
 
+export declare interface AutocompleteCommentRequest {
+  id: string;
+  commentText: string;
+  changeInfo: ChangeInfo;
+  patchsetNumber: RevisionPatchSetNum;
+  filePath: string;
+  range?: CommentRange;
+  lineNumber?: number;
+}
+
 export declare interface SuggestionsProvider {
+  autocompleteComment?(
+    req: AutocompleteCommentRequest
+  ): Promise<AutocompleteCommentResponse>;
+
   /**
    * Gerrit calls these methods when ...
    * - ... user types a comment draft
@@ -55,6 +69,13 @@
   supportedFileExtensions?: string[];
 }
 
+export declare interface AutocompleteCommentResponse {
+  responseCode: ResponseCode;
+  completion?: string;
+  modelVersion?: string;
+  outcome?: number;
+}
+
 export declare interface SuggestCodeResponse {
   responseCode: ResponseCode;
   suggestions: Suggestion[];
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 0fa58f4..b21663a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -264,6 +264,7 @@
     default_base_for_merges: DefaultBase.AUTO_MERGE,
     allow_browser_notifications: false,
     allow_suggest_code_while_commenting: true,
+    allow_autocompleting_comments: true,
     diff_page_sidebar: 'NONE',
   };
 }
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 37a17ba..643f8aa 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -102,6 +102,8 @@
   APPLY_FIX_LOAD = 'ApplyFixLoad',
   // Time to copy target to clipboard
   COPY_TO_CLIPBOARD = 'CopyToClipboard',
+  // Time to autocomplete a comment
+  COMMENT_COMPLETION = 'CommentCompletion',
 }
 
 export enum Interaction {
@@ -156,4 +158,9 @@
   // The very first reporting event with `ChangeId` set when visiting a change
   // related page. Can be used as a starting point for user journeys.
   CHANGE_ID_CHANGED = 'change-id-changed',
+
+  COMMENT_COMPLETION_SUGGESTION_SHOWN = 'comment-completion-suggestion-shown',
+  COMMENT_COMPLETION_SUGGESTION_ACCEPTED = 'comment-completion-suggestion-accepted',
+  COMMENT_COMPLETION_SAVE_DRAFT = 'comment-completion-save-draft',
+  COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched',
 }
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 8ee7669..754a233 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -7,7 +7,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {
   AutocompleteSuggestion,
   AutocompleteQuery,
@@ -222,7 +222,7 @@
       </h3>
       <fieldset>
         <div>
-          <gr-textarea
+          <gr-suggestion-textarea
             class="description"
             autocomplete="on"
             rows="4"
@@ -230,7 +230,7 @@
             ?disabled=${this.computeGroupDisabled()}
             .text=${this.groupConfig?.description ?? ''}
             @text-changed=${this.handleDescriptionTextChanged}
-          ></gr-textarea>
+          ></gr-suggestion-textarea>
         </div>
         <span class="value">
           <gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 256c6a9..5cf71f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -97,14 +97,14 @@
                 <h3 class="heading-3">Description</h3>
                 <fieldset>
                   <div>
-                    <gr-textarea
+                    <gr-suggestion-textarea
                       autocomplete="on"
                       class="description monospace"
                       disabled=""
                       monospace=""
                       rows="4"
                     >
-                    </gr-textarea>
+                    </gr-suggestion-textarea>
                   </div>
                   <span class="value">
                     <gr-button
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 615528c..ff87849 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
@@ -74,6 +74,8 @@
   // private but used in test
   @state() canUpload?: boolean = false; // restAPI can return undefined
 
+  @state() disableSaveWithoutReview = true;
+
   // private but used in test
   @state() inheritFromFilter?: RepoName;
 
@@ -238,7 +240,7 @@
                 ? 'invisible'
                 : ''}
               primary
-              ?disabled=${!this.modified}
+              ?disabled=${!this.modified || this.disableSaveWithoutReview}
               @click=${this.handleSave}
               >Save</gr-button
             >
@@ -343,6 +345,7 @@
         this.groups = res.groups;
         this.weblinks = res.config_web_links || [];
         this.canUpload = res.can_upload;
+        this.disableSaveWithoutReview = !!res.require_change_for_config_update;
         this.ownerOf = res.owner_of || [];
         return toSortedPermissionsArray(this.local);
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 467857d..495de52 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
@@ -157,12 +157,13 @@
                 Edit
               </gr-button>
               <gr-button
-                aria-disabled="false"
+                aria-disabled="true"
+                disabled=""
                 class="invisible"
                 id="saveBtn"
                 primary=""
                 role="button"
-                tabindex="0"
+                tabindex="-1"
               >
                 Save
               </gr-button>
@@ -1445,9 +1446,18 @@
       );
 
       element.repo = 'test-repo' as RepoName;
+      await element.updateComplete;
       sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
-
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
       element.modified = true;
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        false
+      );
       queryAndAssert<GrButton>(element, '#saveBtn').click();
       await element.updateComplete;
       assert.equal(
@@ -1460,6 +1470,58 @@
       assert.isTrue(setUrlStub.notCalled);
     });
 
+    test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(
+          JSON.parse(
+            JSON.stringify({
+              ...accessRes,
+              require_change_for_config_update: true,
+            })
+          )
+        )
+      );
+
+      element.repo = 'test-repo' as RepoName;
+      await element.updateComplete;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
+      element.modified = true;
+      await element.updateComplete;
+      assert.equal(element.disableSaveWithoutReview, true);
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
+    });
+
     test('handleSaveForReview', async () => {
       const repoAccessInput = {
         add: {
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 1a0eeea..7edfe2b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -208,7 +208,7 @@
       return;
 
     return html`
-      <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
+      <h2 class="heading-2">${this.repoConfig?.actions['gc']?.label}</h2>
       <gr-button
         title=${this.repoConfig?.actions['gc']?.title || ''}
         ?loading=${this.runningGC}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 90277534..6e1aba7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -9,8 +9,9 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   ConfigInfo,
   RepoName,
@@ -39,11 +40,14 @@
 import {deepClone} from '../../../utils/deep-util';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {createChangeUrl} from '../../../models/views/change';
 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';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const STATES = {
   active: {value: RepoState.ACTIVE, label: 'Active'},
@@ -101,6 +105,11 @@
   // private but used in test
   @state() readOnly = true;
 
+  @state() showSaveForReviewButton = false;
+
+  // private but used in test
+  @state() disableSaveWithoutReview = true;
+
   @state() private states = Object.values(STATES);
 
   @state() private originalConfig?: ConfigInfo;
@@ -114,10 +123,14 @@
 
   @state() private pluginConfigChanged = false;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     subscribe(
@@ -195,10 +208,20 @@
                 ${this.renderDescription()} ${this.renderRepoOptions()}
                 ${this.renderPluginConfig()}
                 <gr-button
-                  ?disabled=${this.readOnly || !configChanged}
+                  id="saveBtn"
+                  ?disabled=${this.readOnly ||
+                  this.disableSaveWithoutReview ||
+                  !configChanged}
                   @click=${this.handleSaveRepoConfig}
                   >Save changes</gr-button
                 >
+                <gr-button
+                  id="saveReviewBtn"
+                  ?disabled=${this.readOnly || !configChanged}
+                  ?hidden=${!this.showSaveForReviewButton}
+                  @click=${this.handleSaveRepoConfigForReview}
+                  >Save for review</gr-button
+                >
               </fieldset>
               <gr-endpoint-decorator name="repo-config">
                 <gr-endpoint-param
@@ -244,7 +267,7 @@
     return html`
       <h3 id="Description" class="heading-3">Description</h3>
       <fieldset>
-        <gr-textarea
+        <gr-suggestion-textarea
           id="descriptionInput"
           class="description"
           autocomplete="on"
@@ -254,7 +277,7 @@
           ?disabled=${this.readOnly}
           .text=${this.repoConfig.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
-        ></gr-textarea>
+        ></gr-suggestion-textarea>
       </fieldset>
     `;
   }
@@ -781,6 +804,11 @@
 
             // If the user is not an owner, is_owner is not a property.
             this.readOnly = !access[repo]?.is_owner;
+            this.showSaveForReviewButton = this.flagsService.isEnabled(
+              KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+            );
+            this.disableSaveWithoutReview =
+              !!access[repo]?.require_change_for_config_update;
           });
         }
       })
@@ -815,7 +843,7 @@
         config.max_object_size_limit.configured_value = '';
       }
       this.repoConfig = config;
-      this.originalConfig = deepClone(config) as ConfigInfo;
+      this.originalConfig = deepClone<ConfigInfo>(config);
       this.loading = false;
     };
     promises.push(repoConfigHelper());
@@ -920,11 +948,37 @@
       this.repo,
       this.formatRepoConfigForSave(this.repoConfig)
     );
-    this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+    this.originalConfig = deepClone<ConfigInfo>(this.repoConfig);
     this.pluginConfigChanged = false;
     return;
   }
 
+  async handleSaveRepoConfigForReview(e: Event) {
+    if (!this.repoConfig || !this.repo)
+      return Promise.reject(new Error('undefined repoConfig or repo'));
+    const button = e && (e.target as GrButton);
+    if (button) {
+      button.loading = true;
+    }
+
+    return this.restApiService
+      .saveRepoConfigForReview(
+        this.repo,
+        this.formatRepoConfigForSave(this.repoConfig)
+      )
+      .then(change => {
+        // Don't navigate on server error.
+        if (change) {
+          this.getNavigation().setUrl(createChangeUrl({change}));
+        }
+      })
+      .finally(() => {
+        if (button) {
+          button.loading = false;
+        }
+      });
+  }
+
   private isEdited(
     original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
     repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
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 4deb99a..a4e0f09 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
@@ -6,6 +6,8 @@
 import '../../../test/common-test-setup';
 import './gr-repo';
 import {GrRepo} from './gr-repo';
+import {createChange} from '../../../test/test-data-generators';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {mockPromise} from '../../../test/test-utils';
 import {
   addListenerForTest,
@@ -17,6 +19,7 @@
   createInheritedBoolean,
   createServerInfo,
 } from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
 import {
   ConfigInfo,
   GitRef,
@@ -25,6 +28,7 @@
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
+  ProjectAccessInfo,
   RepoAccessGroups,
   RepoAccessInfoMap,
   RepoName,
@@ -32,6 +36,7 @@
 import {
   ConfigParameterInfoType,
   InheritedBooleanInfoConfiguredValue,
+  PermissionAction,
   RepoState,
   SubmitType,
 } from '../../../constants/constants';
@@ -42,9 +47,12 @@
 import {PageErrorEvent} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
 import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {ChangeInfo} from '../../../api/rest-api';
 
 suite('gr-repo tests', () => {
   let element: GrRepo;
@@ -199,7 +207,7 @@
             <fieldset>
               <h3 class="heading-3" id="Description">Description</h3>
               <fieldset>
-                <gr-textarea
+                <gr-suggestion-textarea
                   autocomplete="on"
                   class="description monospace"
                   disabled=""
@@ -208,7 +216,7 @@
                   placeholder="<Insert repo description here>"
                   rows="4"
                 >
-                </gr-textarea>
+                </gr-suggestion-textarea>
               </fieldset>
               <h3 class="heading-3" id="Options">Repository Options</h3>
               <fieldset id="options">
@@ -375,6 +383,7 @@
                 </section>
               </fieldset>
               <gr-button
+                id="saveBtn"
                 aria-disabled="true"
                 disabled=""
                 role="button"
@@ -382,6 +391,16 @@
               >
                 Save changes
               </gr-button>
+              <gr-button
+                id="saveReviewBtn"
+                aria-disabled="true"
+                disabled=""
+                hidden=""
+                role="button"
+                tabindex="-1"
+              >
+                Save for review
+              </gr-button>
             </fieldset>
             <gr-endpoint-decorator name="repo-config">
               <gr-endpoint-param name="repoName"> </gr-endpoint-param>
@@ -633,31 +652,35 @@
   });
 
   suite('admin', () => {
+    const testRepoAccess: ProjectAccessInfo = {
+      revision: 'xxxx',
+      local: {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {xxx: {action: PermissionAction.ALLOW, force: false}},
+            },
+          },
+        },
+      },
+      is_owner: true,
+      owner_of: ['refs/*'] as GitRef[],
+      groups: {
+        xxxx: {
+          id: 'xxxx' as GroupId,
+          url: 'test',
+          name: 'test' as GroupName,
+        },
+      } as RepoAccessGroups,
+      config_web_links: [{name: 'gitiles', url: 'test'}],
+    };
+    let getRepoAccessStub: sinon.SinonStub;
     setup(() => {
       element.repo = REPO as RepoName;
       loggedInStub.returns(Promise.resolve(true));
-      stubRestApi('getRepoAccess').callsFake(() =>
+      getRepoAccessStub = stubRestApi('getRepoAccess').callsFake(() =>
         Promise.resolve({
-          'test-repo': {
-            revision: 'xxxx',
-            local: {
-              'refs/*': {
-                permissions: {
-                  owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
-                },
-              },
-            },
-            is_owner: true,
-            owner_of: ['refs/*'] as GitRef[],
-            groups: {
-              xxxx: {
-                id: 'xxxx' as GroupId,
-                url: 'test',
-                name: 'test' as GroupName,
-              },
-            } as RepoAccessGroups,
-            config_web_links: [{name: 'gitiles', url: 'test'}],
-          },
+          'test-repo': testRepoAccess,
         } as RepoAccessInfoMap)
       );
     });
@@ -720,7 +743,7 @@
 
       await element.loadRepo();
 
-      const button = queryAll<GrButton>(element, 'gr-button')[2];
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
@@ -728,7 +751,7 @@
           '#Title'
         ).classList.contains('edited')
       );
-      queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+      queryAndAssert<GrSuggestionTextarea>(element, '#descriptionInput').text =
         configInputObj.description;
       queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
         configInputObj.state;
@@ -800,5 +823,76 @@
         saveStub.lastCall.calledWithExactly(REPO as RepoName, configInputObj)
       );
     });
+
+    test('saveReviewBtn visible when experiment is enabled', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      await element.loadRepo();
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(
+        element,
+        'gr-button#saveReviewBtn'
+      );
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+
+    test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      getRepoAccessStub.callsFake(() =>
+        Promise.resolve({
+          'test-repo': {
+            ...testRepoAccess,
+            require_change_for_config_update: true,
+          },
+        } as RepoAccessInfoMap)
+      );
+      await element.loadRepo();
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+      assert.isTrue(button.hasAttribute('disabled'));
+    });
+
+    test('saveReviewBtn', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
+      const saveForReviewStub = stubRestApi('saveRepoConfigForReview').returns(
+        new Promise(r => (resolver = r))
+      );
+      resolver!(createChange());
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      await element.loadRepo();
+      await element.updateComplete;
+      const input = queryAndAssert<GrSuggestionTextarea>(
+        element,
+        '#descriptionInput'
+      );
+      input.text = 'New description';
+      await input.updateComplete;
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+      assert.isFalse(button.hasAttribute('disabled'));
+      queryAndAssert<GrButton>(element, 'gr-button#saveReviewBtn').click();
+      await element.updateComplete;
+      assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(
+        setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 50ec339..836d86a 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
@@ -485,7 +485,7 @@
 
   @state() pluginsLoaded = false;
 
-  @state() threadsWithSuggestions?: CommentThread[];
+  @state() threadsWithUnappliedSuggestions?: CommentThread[];
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -579,8 +579,8 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threadsWithSuggestions$,
-      x => (this.threadsWithSuggestions = x)
+      () => this.getCommentsModel().threadsWithUnappliedSuggestions$,
+      x => (this.threadsWithUnappliedSuggestions = x)
     );
   }
 
@@ -820,11 +820,11 @@
           <div class="header" slot="header">Publish Change Edit</div>
           <div class="main" slot="main">
             ${when(
-              this.numberOfThreadsWithSuggestions() > 0,
+              this.numberOfThreadsWithUnappliedSuggestions() > 0,
               () => html`<p class="info">
                 <gr-icon id="icon" icon="info" small></gr-icon>
-                Heads Up! ${this.numberOfThreadsWithSuggestions()} comments have
-                suggestions you can apply before publishing
+                Heads Up! ${this.numberOfThreadsWithUnappliedSuggestions()}
+                comments have suggestions you can apply before publishing
               </p>`
             )}
             Do you really want to publish the edit?
@@ -2105,8 +2105,16 @@
   }
 
   private handlePublishEditTap() {
-    assertIsDefined(this.confirmPublishEditDialog, 'confirmPublishEditDialog');
-    this.showActionDialog(this.confirmPublishEditDialog);
+    if (this.numberOfThreadsWithUnappliedSuggestions() > 0) {
+      assertIsDefined(
+        this.confirmPublishEditDialog,
+        'confirmPublishEditDialog'
+      );
+      this.showActionDialog(this.confirmPublishEditDialog);
+    } else {
+      // Skip confirmation dialog and publish immediately.
+      this.handlePublishEditConfirm();
+    }
   }
 
   private handleRebaseEditTap() {
@@ -2264,9 +2272,9 @@
     fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
   }
 
-  private numberOfThreadsWithSuggestions() {
-    if (!this.threadsWithSuggestions) return 0;
-    return this.threadsWithSuggestions.length;
+  private numberOfThreadsWithUnappliedSuggestions() {
+    if (!this.threadsWithUnappliedSuggestions) return 0;
+    return this.threadsWithUnappliedSuggestions.length;
   }
 }
 
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 a926f26..ebcec4b 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
@@ -2443,6 +2443,7 @@
         element.latestPatchNum = 12 as PatchSetNumber;
         element.change = {
           ...createChangeViewChange(),
+          current_revision_number: element.latestPatchNum,
           revisions: createRevisions(element.latestPatchNum as number),
           messages: createChangeMessages(1),
         };
@@ -2458,11 +2459,11 @@
       suite('happy path', () => {
         let executeChangeActionStub: sinon.SinonStub;
         setup(() => {
-          stubRestApi('getChangeDetail').returns(
+          stubRestApi('getChange').returns(
             Promise.resolve({
               ...createChangeViewChange(),
               // element has latest info
-              revisions: createRevisions(element.latestPatchNum as number),
+              current_revision_number: element.latestPatchNum!,
               messages: createChangeMessages(1),
             })
           );
@@ -2535,9 +2536,6 @@
               })
             );
             executeChangeActionStub.resolves(response);
-            stubRestApi('getChange').returns(
-              Promise.resolve(createChangeViewChange())
-            );
             await element.send(
               HttpMethod.POST,
               {message: 'Revert'},
@@ -2625,13 +2623,12 @@
 
       suite('failure modes', () => {
         test('non-latest', () => {
-          stubRestApi('getChangeDetail').returns(
+          stubRestApi('getChange').returns(
             Promise.resolve({
               ...createChangeViewChange(),
               // new patchset was uploaded
-              revisions: createRevisions(
-                (element.latestPatchNum as number) + 1
-              ),
+              current_revision_number: (element.latestPatchNum! +
+                1) as PatchSetNumber,
               messages: createChangeMessages(1),
             })
           );
@@ -2655,11 +2652,11 @@
         });
 
         test('send fails', () => {
-          stubRestApi('getChangeDetail').returns(
+          stubRestApi('getChange').returns(
             Promise.resolve({
               ...createChangeViewChange(),
               // element has latest info
-              revisions: createRevisions(element.latestPatchNum as number),
+              current_revision_number: element.latestPatchNum!,
               messages: createChangeMessages(1),
             })
           );
@@ -2692,14 +2689,29 @@
         });
 
         test('revert single change change not reachable', async () => {
-          stubRestApi('getChangeDetail').returns(
-            Promise.resolve({
-              ...createChangeViewChange(),
-              // element has latest info
-              revisions: createRevisions(element.latestPatchNum as number),
-              messages: createChangeMessages(1),
-            })
-          );
+          let getChangeCall = 0;
+          let errorFired = false;
+          stubRestApi('getChange').callsFake((_, errFn) => {
+            ++getChangeCall;
+            if (getChangeCall === 1) {
+              return Promise.resolve({
+                ...createChangeViewChange(),
+                // element has latest info
+                current_revision_number: element.latestPatchNum!,
+                messages: createChangeMessages(1),
+              });
+            } else {
+              // Mimics the behaviour of gr-rest-api-impl: If errFn is passed
+              // call it and return undefined, otherwise call fireNetworkError
+              // or fireServerError.
+              if (errFn) {
+                errFn.call(undefined);
+              } else {
+                errorFired = true;
+              }
+              return Promise.resolve(undefined);
+            }
+          });
           const setUrlStub = sinon.stub(
             testResolver(navigationToken),
             'setUrl'
@@ -2715,18 +2727,6 @@
               _number: 12345,
             })
           );
-          let errorFired = false;
-          // Mimics the behaviour of gr-rest-api-impl: If errFn is passed call
-          // it and return undefined, otherwise call fireNetworkError or
-          // fireServerError.
-          stubRestApi('getChange').callsFake((_, errFn) => {
-            if (errFn) {
-              errFn.call(undefined);
-            } else {
-              errorFired = true;
-            }
-            return Promise.resolve(undefined);
-          });
 
           await element.send(
             HttpMethod.POST,
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 65d36ec..77610dc6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -50,6 +50,9 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {fire} from '../../../utils/event-util';
 import {ChangeMessageDeletedEventDetail} from '../../../types/events';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -97,9 +100,6 @@
     return this.message?.author || this.message?.updated_by;
   }
 
-  @property({type: Object})
-  config?: ServerInfo;
-
   @property({type: Boolean})
   hideAutomated = false;
 
@@ -110,11 +110,14 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @property({type: Boolean})
+  @state()
   loggedIn = false;
 
   @state()
-  private isAdmin = false;
+  config?: ServerInfo;
+
+  @state()
+  isAdmin = false;
 
   @state()
   private isDeletingChangeMsg = false;
@@ -123,22 +126,28 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
   constructor() {
     super();
     this.addEventListener('click', e => this.handleClick(e));
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then(config => {
-      this.config = config;
-    });
-    this.restApiService.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.restApiService.getIsAdmin().then(isAdmin => {
-      this.isAdmin = !!isAdmin;
-    });
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      x => (this.config = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().isAdmin$,
+      x => (this.isAdmin = x)
+    );
   }
 
   static override get styles() {
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 1ed2729..1dbacc1 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
@@ -49,8 +49,8 @@
 
   suite('when admin and logged in', () => {
     setup(async () => {
-      stubRestApi('getIsAdmin').returns(Promise.resolve(true));
       element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
+      element.isAdmin = true;
     });
 
     test('can see delete button', async () => {
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 7204035..c76e928 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
@@ -88,6 +88,7 @@
   fireNoBubble,
   fireIronAnnounce,
   fireServerError,
+  fireReload,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
 import {DelayedTask} from '../../../utils/async-util';
@@ -122,7 +123,7 @@
 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 {accountsModelToken} from '../../../models/accounts/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';
@@ -1459,27 +1460,45 @@
     this.getNavigation().blockNavigation('sending review');
     return this.saveReview(reviewInput, errFn)
       .then(result => {
+        // change-info is not set only if request resulted in error.
+        if (!result?.change_info) {
+          return;
+        }
+
+        // saveReview response don't contain revision information, if the
+        // newer patchset was uploaded in the meantime, we should reload.
+        const reloadRequired =
+          result.change_info.current_revision_number !==
+          this.change?.current_revision_number;
+        // Update the state right away to update comments, even if the full
+        // reload is scheduled right after.
+        const updatedChange = {
+          ...result.change_info,
+          revisions: this.change?.revisions,
+          current_revision: this.change?.current_revision,
+          current_revision_number: this.change?.current_revision_number,
+        };
         this.getChangeModel().updateStateChange(
-          GrReviewerUpdatesParser.parse(
-            result?.change_info as ChangeViewChangeInfo
-          )
+          GrReviewerUpdatesParser.parse(updatedChange as ChangeViewChangeInfo)
         );
+        if (reloadRequired) {
+          fireReload(this);
+        }
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
         fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
         this.getPluginLoader().jsApiService.handleReplySent();
-        return;
       })
-      .then(result => result)
       .finally(() => {
         this.getNavigation().releaseNavigation('sending review');
         this.disabled = false;
         if (this.patchsetLevelGrComment) {
           this.patchsetLevelGrComment.disableAutoSaving = false;
         }
-        // By this point in time the change has loaded, we're only waiting for the comments.
+        // The request finished and reloads if necessary are asynchronously
+        // scheduled.
         this.reporting.timeEnd(Timing.SEND_REPLY);
       });
   }
@@ -1912,7 +1931,7 @@
       this.latestPatchNum,
       review,
       errFn,
-      true
+      /* fetchDetail=*/ true
     );
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 500aa63..7c8aece 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
@@ -15,6 +15,7 @@
   queryAndAssert,
   stubReporting,
   stubRestApi,
+  waitEventLoop,
   waitUntilVisible,
 } from '../../../test/test-utils';
 import {
@@ -30,6 +31,7 @@
   createComment,
   createCommentThread,
   createDraft,
+  createLabelInfo,
   createRevision,
   createServiceUserWithId,
 } from '../../../test/test-data-generators';
@@ -37,6 +39,7 @@
 import {
   AccountId,
   AccountInfo,
+  ChangeInfo,
   CommentThread,
   CommitId,
   DetailedLabelInfo,
@@ -71,6 +74,8 @@
 import {isOwner} from '../../../utils/change-util';
 import {createNewPatchsetLevel} from '../../../utils/comment-util';
 import {Timing} from '../../../constants/reporting';
+import {ParsedChangeInfo} from '../../../types/types';
+import {changeModelToken} from '../../../models/change/change-model';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -97,6 +102,8 @@
   let changeNum: NumericChangeId;
   let latestPatchNum: PatchSetNumber;
   let commentsModel: CommentsModel;
+  let change: ParsedChangeInfo;
+  let changeNoRevisions: ChangeInfo;
 
   let lastId = 1;
   const makeAccount = function () {
@@ -112,15 +119,13 @@
   setup(async () => {
     changeNum = 42 as NumericChangeId;
     latestPatchNum = 1 as PatchSetNumber;
+    const owner: AccountInfo = {
+      _account_id: 999 as AccountId,
+      display_name: 'Kermit',
+      email: 'abcd' as EmailAddress,
+    };
 
-    stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
-    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
-
-    element = await fixture<GrReplyDialog>(html`
-      <gr-reply-dialog></gr-reply-dialog>
-    `);
-
-    element.change = {
+    changeNoRevisions = {
       ...createChange(),
       _number: changeNum,
       owner: {
@@ -148,7 +153,23 @@
           default_value: 0,
         },
       },
+
+      current_revision_number: 1 as PatchSetNumber,
     };
+    change = {
+      ...changeNoRevisions,
+      revisions: {'commit-id': {...createRevision(), uploader: owner}},
+      current_revision: 'commit-id' as CommitId,
+    };
+
+    stubRestApi('getChange').returns(Promise.resolve(change as ChangeInfo));
+    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
+
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
+
+    element.change = change;
     element.latestPatchNum = latestPatchNum;
     element.permittedLabels = {
       'Code-Review': ['-1', ' 0', '+1'],
@@ -186,6 +207,9 @@
     });
     stubSaveReview((review: ReviewInput) => {
       resolver(review);
+      return {
+        change_info: changeNoRevisions,
+      };
     });
     return promise;
   }
@@ -1467,9 +1491,9 @@
         ...createChange(),
         status: ChangeStatus.NEW,
       };
+      const restApiPromise = interceptSaveReview();
       element.send(false, false);
-
-      await waitUntil(() => fireStub.called);
+      await restApiPromise;
 
       const events = fireStub.args.map(arg => arg[0].type || '');
       assert.isFalse(events.includes('show-alert'));
@@ -1489,9 +1513,9 @@
         status: ChangeStatus.NEW,
         work_in_progress: true,
       };
+      const restApiPromise = interceptSaveReview();
       element.send(false, true);
-
-      await waitUntil(() => fireStub.called);
+      await restApiPromise;
 
       const events = fireStub.args.map(arg => arg[0].type || '');
       assert.isFalse(events.includes('show-alert'));
@@ -1635,17 +1659,15 @@
 
   test('only send labels that have changed', async () => {
     await element.updateComplete;
+    const promise = mockPromise();
     stubSaveReview((review: ReviewInput) => {
       assert.deepEqual(review?.labels, {
         'Code-Review': 0,
         Verified: -1,
       });
-    });
-
-    const promise = mockPromise();
-    element.addEventListener('send', () => {
       promise.resolve();
     });
+
     // Without wrapping this test in await element.updateComplete, the below two
     // calls to tap() cause a race in some situations in shadow DOM. The send
     // button can be tapped before the others, causing the test to fail.
@@ -2616,6 +2638,92 @@
     );
   });
 
+  test('reload change if patchset updated', async () => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await element.updateComplete;
+    const changeModel = testResolver(changeModelToken);
+    const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
+    const responseChange = {
+      ...change,
+      labels: {Verified: createLabelInfo(-1)},
+      revisions: undefined,
+      current_revision: undefined,
+      current_revision_number: (change.current_revision_number +
+        1) as PatchSetNumber,
+    };
+    stubSaveReview(() => {
+      return {
+        change_info: responseChange as ChangeInfo,
+      };
+    });
+    const reloadPromise = mockPromise();
+    let reloadTriggered = false;
+    document.addEventListener('reload', () => {
+      reloadTriggered = true;
+      reloadPromise.resolve();
+    });
+
+    // Set a different label value
+    const el = queryAndAssert<GrLabelScoreRow>(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Verified"]'
+    );
+    el.setSelectedValue('-1');
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.send').click();
+    await element.updateComplete;
+
+    await reloadPromise;
+    assert.isTrue(reloadTriggered);
+
+    // All revision information is old, but all other information is new.
+    const expectedChange = {...change, labels: {Verified: createLabelInfo(-1)}};
+    assert.deepEqual(changeStateUpdateSpy.firstCall.args[0], expectedChange);
+  });
+
+  test('no reload if patchset is the same', async () => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await element.updateComplete;
+    const changeModel = testResolver(changeModelToken);
+    const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
+    const responseChange = {
+      ...change,
+      labels: {Verified: createLabelInfo(-1)},
+      revisions: undefined,
+      current_revision: undefined,
+      current_revision_number: change.current_revision_number,
+    };
+    stubSaveReview(() => {
+      return {
+        change_info: responseChange as ChangeInfo,
+      };
+    });
+    let reloadTriggered = false;
+    document.addEventListener('reload', () => {
+      reloadTriggered = true;
+    });
+
+    // Set a different label value
+    const el = queryAndAssert<GrLabelScoreRow>(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Verified"]'
+    );
+    el.setSelectedValue('-1');
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.send').click();
+    await element.updateComplete;
+
+    await waitEventLoop();
+    assert.isFalse(reloadTriggered);
+    // All revision information is old, but all other information is new.
+    const expectedChange = {...change, labels: {Verified: createLabelInfo(-1)}};
+    assert.deepEqual(changeStateUpdateSpy.firstCall.args[0], expectedChange);
+  });
+
   suite('mention users', () => {
     setup(async () => {
       element.account = createAccountWithId(1);
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 2916f75..644871a4 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
@@ -7,12 +7,14 @@
 import '../../shared/gr-avatar/gr-avatar';
 import {getUserName} from '../../../utils/display-name-util';
 import {AccountInfo, DropdownLink, ServerInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 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';
+import {customElement, property, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -30,34 +32,54 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  @property({type: Object})
+  @property({type: Boolean})
+  showMobile?: boolean;
+
+  // Private but used in test
+  @state()
   config?: ServerInfo;
 
-  @property({type: String})
-  _path = '/';
+  @state()
+  private path = '/';
 
-  @property({type: Boolean})
-  _hasAvatars = false;
+  @state()
+  private hasAvatars = false;
 
-  @property({type: String})
-  _switchAccountUrl = '';
+  @state()
+  private switchAccountUrl = '';
 
-  private readonly restApiService = getAppContext().restApiService;
+  // private but used in test
+  @state() feedbackURL = '';
+
+  // Private but used in test
+  readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      cfg => {
+        this.config = cfg;
+
+        if (cfg?.gerrit?.report_bug_url) {
+          this.feedbackURL = cfg?.gerrit.report_bug_url;
+        }
+
+        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+          this.switchAccountUrl = cfg.auth.switch_account_url;
+        } else {
+          this.switchAccountUrl = '';
+        }
+        this.hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
     this.handleLocationChange();
     document.addEventListener('location-change', this.handleLocationChange);
-    this.restApiService.getConfig().then(cfg => {
-      this.config = cfg;
-
-      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
-        this._switchAccountUrl = cfg.auth.switch_account_url;
-      } else {
-        this._switchAccountUrl = '';
-      }
-      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-    });
   }
 
   override disconnectedCallback() {
@@ -88,15 +110,17 @@
       link=""
       .items=${this.links}
       .topContent=${this.topContent}
-      @tap-item-shortcuts=${this._handleShortcutsTap}
+      @tap-item-shortcuts=${this.handleShortcutsTap}
       .horizontalAlign=${'right'}
     >
-      <span ?hidden=${this._hasAvatars}
-        >${this._accountName(this.account)}</span
-      >
+      ${this.showMobile && !this.hasAvatars
+        ? html`<gr-icon icon="account_circle" filled></gr-icon>`
+        : html`<span ?hidden=${this.hasAvatars}
+            >${this.accountName(this.account)}</span
+          >`}
       <gr-avatar
         .account=${this.account}
-        ?hidden=${!this._hasAvatars}
+        ?hidden=${!this.hasAvatars}
         .imageSize=${56}
         aria-label="Account avatar"
       ></gr-avatar>
@@ -104,14 +128,15 @@
   }
 
   get links(): DropdownLink[] | undefined {
-    return this._getLinks(this._switchAccountUrl, this._path);
+    return this.getLinks(this.switchAccountUrl, this.path);
   }
 
   get topContent(): DropdownContent[] | undefined {
-    return this._getTopContent(this.account);
+    return this.getTopContent(this.account);
   }
 
-  _getLinks(switchAccountUrl?: string, path?: string) {
+  // Private but used in test
+  getLinks(switchAccountUrl?: string, path?: string) {
     if (switchAccountUrl === undefined || path === undefined) {
       return undefined;
     }
@@ -121,37 +146,48 @@
     links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
-      const url = this._interpolateUrl(switchAccountUrl, replacements);
+      const url = this.interpolateUrl(switchAccountUrl, replacements);
       links.push({name: 'Switch account', url, external: true});
     }
+    if (this.showMobile && this.feedbackURL) {
+      links.push({
+        name: 'Feedback',
+        id: 'feedback',
+        url: this.feedbackURL,
+        external: true,
+        target: '_blank',
+      });
+    }
     links.push({name: 'Sign out', id: 'signout', url: '/logout'});
     return links;
   }
 
-  _getTopContent(account?: AccountInfo) {
+  // Private but used in test
+  getTopContent(account?: AccountInfo) {
     return [
-      {text: this._accountName(account), bold: true},
+      {text: this.accountName(account), bold: true},
       {text: account?.email ? account.email : ''},
     ] as DropdownContent[];
   }
 
-  _handleShortcutsTap() {
+  private handleShortcutsTap() {
     fire(this, 'show-keyboard-shortcuts', {});
   }
 
   private readonly handleLocationChange = () => {
-    this._path =
+    this.path =
       window.location.pathname + window.location.search + window.location.hash;
   };
 
-  _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+  // Private but used in test
+  interpolateUrl(url: string, replacements: {[key: string]: string}) {
     return url.replace(
       INTERPOLATE_URL_PATTERN,
       (_, p1) => replacements[p1] || ''
     );
   }
 
-  _accountName(account?: AccountInfo) {
+  private accountName(account?: AccountInfo) {
     return getUserName(this.config, account);
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index c224a6b..06dc3f8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -78,14 +78,14 @@
 
   test('switch account', () => {
     // Missing params.
-    assert.isUndefined(element._getLinks());
-    assert.isUndefined(element._getLinks(undefined));
+    assert.isUndefined(element.getLinks());
+    assert.isUndefined(element.getLinks(undefined));
 
     // No switch account link.
-    assert.equal(element._getLinks('', '')!.length, 3);
+    assert.equal(element.getLinks('', '')!.length, 3);
 
     // Unparameterized switch account link.
-    let links = element._getLinks('/switch-account', '')!;
+    let links = element.getLinks('/switch-account', '')!;
     assert.equal(links.length, 4);
     assert.deepEqual(links[2], {
       name: 'Switch account',
@@ -94,7 +94,7 @@
     });
 
     // Parameterized switch account link.
-    links = element._getLinks('/switch-account${path}', '/c/123')!;
+    links = element.getLinks('/switch-account${path}', '/c/123')!;
     assert.equal(links.length, 4);
     assert.deepEqual(links[2], {
       name: 'Switch account',
@@ -103,13 +103,13 @@
     });
   });
 
-  test('_interpolateUrl', () => {
+  test('interpolateUrl', () => {
     const replacements = {
       foo: 'bar',
       test: 'TEST',
     };
     const interpolate = (url: string) =>
-      element._interpolateUrl(url, replacements);
+      element.interpolateUrl(url, replacements);
 
     assert.equal(interpolate('test'), 'test');
     assert.equal(interpolate('${test}'), 'TEST');
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 ae59fb6..d133bca 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
@@ -22,13 +22,14 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 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';
 import {subscribe} from '../../lit/subscription-controller';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -104,6 +105,9 @@
   AuthType.CUSTOM_EXTENSION,
 ]);
 
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-main-header': GrMainHeader;
@@ -149,6 +153,15 @@
   // private but used in test
   @state() feedbackURL = '';
 
+  @state() hamburgerClose? = false;
+
+  @query('.nav-sidebar') navSidebar?: HTMLDivElement;
+
+  @query('.modelBackground') modelBackground?: HTMLDivElement;
+
+  @query('.has-collapsible.active')
+  hasCollapsibleActive?: HTMLLIElement;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -202,10 +215,29 @@
         :host {
           display: block;
         }
-        nav {
+        .hideOnDesktop {
+          display: none;
+        }
+
+        nav.hideOnMobile {
           align-items: center;
           display: flex;
         }
+        nav.hideOnMobile ul {
+          list-style: none;
+          padding-left: var(--spacing-l);
+        }
+        nav.hideOnMobile .links > li {
+          cursor: default;
+          display: inline-block;
+          padding: 0;
+          position: relative;
+        }
+
+        .mobileTitle {
+          display: none;
+        }
+
         .bigTitle {
           color: var(--header-text-color);
           font-size: var(--header-title-font-size);
@@ -215,7 +247,8 @@
         .bigTitle:hover {
           text-decoration: underline;
         }
-        .titleText {
+        .titleText,
+        .mobileTitleText {
           /* Vertical alignment of icons and text with just block/inline display is too troublesome. */
           display: flex;
           align-items: center;
@@ -239,16 +272,45 @@
           content: var(--header-title-content);
           white-space: nowrap;
         }
-        ul {
-          list-style: none;
-          padding-left: var(--spacing-l);
+
+        .mobileTitleText::before {
+          --icon-width: var(
+            --header-icon-width,
+            var(--header-mobile-icon-size, var(--header-icon-size, 0))
+          );
+          --icon-height: var(
+            --header-icon-height,
+            var(--header-mobile-icon-size, var(--header-icon-size, 0))
+          );
+          background-image: var(--header-mobile-icon, var(--header-icon));
+          background-size: var(--mobile-icon-width, var(--icon-width))
+            var(--mobile-icon-height, var(--icon-height));
+          background-repeat: no-repeat;
+          content: '';
+          /* Any direct child of a flex element implicitly has 'display: block', but let's make that explicit here. */
+          display: block;
+          width: var(--mobile-icon-width, var(--icon-width));
+          height: var(--mobile-icon-height, var(--icon-height));
+          /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+          margin-right: clamp(
+            0px,
+            var(--mobile-icon-height, var(--icon-height)),
+            var(--spacing-m)
+          );
         }
-        .links > li {
-          cursor: default;
-          display: inline-block;
-          padding: 0;
-          position: relative;
+        .mobileTitleText::after {
+          /* The height will be determined by the line-height of the .bigTitle element. */
+          content: var(
+            --header-mobile-title-content,
+            var(--header-title-content)
+          );
+          white-space: nowrap;
+          text-overflow: ellipsis;
+          flex: 1;
+          overflow: hidden;
+          min-width: 0;
         }
+
         .linksTitle {
           display: inline-block;
           font-weight: var(--font-weight-bold);
@@ -264,7 +326,24 @@
           flex: 1;
           justify-content: flex-end;
         }
-        .rightItems gr-endpoint-decorator:not(:empty) {
+        .mobileRightItems {
+          align-items: center;
+          justify-content: flex-end;
+
+          display: inline-block;
+          vertical-align: middle;
+          cursor: pointer;
+          position: relative;
+          top: 0px;
+          right: 0px;
+          margin-right: 0;
+          margin-left: auto;
+          min-height: 50px;
+          padding-top: 12px;
+        }
+
+        .rightItems gr-endpoint-decorator:not(:empty),
+        .mobileRightItems gr-endpoint-decorator:not(:empty) {
           margin-left: var(--spacing-l);
         }
         gr-smart-search {
@@ -299,13 +378,17 @@
         }
         :host([loading]) .accountContainer,
         :host([loggedIn]) .loginButton,
-        :host([loggedIn]) .registerButton {
+        :host([loggedIn]) .registerButton,
+        :host([loggedIn]) .moreMenu {
           display: none;
         }
         :host([loggedIn]) .settingsButton,
         :host([loggedIn]) gr-account-dropdown {
           display: inline;
         }
+        :host:not([loggedIn]) .moreMenu {
+          display: inline;
+        }
         .accountContainer {
           flex: 0 0 auto;
           align-items: center;
@@ -340,6 +423,10 @@
           --gr-button-text-color: var(--header-text-color);
           color: var(--header-text-color);
         }
+        .hamburger-open {
+          --gr-button-text-color: var(--primary-text-color);
+          color: var(--primary-text-color);
+        }
         #mobileSearch {
           display: none;
         }
@@ -363,7 +450,158 @@
             margin-left: var(--spacing-m) !important;
           }
           gr-dropdown {
-            padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+            padding: 0 var(--spacing-m);
+          }
+          .nav-sidebar {
+            background: var(--table-header-background-color);
+            width: 200px;
+            height: 100%;
+            display: block;
+            position: fixed;
+            left: -200px;
+            top: 0px;
+            transition: left 0.25s ease;
+            margin: 0;
+            border: 0;
+            overflow-y: auto;
+            overflow-x: hidden;
+            height: 100%;
+            margin-bottom: 15px 0;
+            box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+            border-radius: 3px;
+            z-index: 2;
+          }
+          .nav-sidebar.visible {
+            left: 0px;
+            transition: left 0.25s ease;
+            width: 80%;
+            z-index: 200;
+          }
+          .mobileTitle {
+            position: relative;
+            display: block;
+            top: 10px;
+            font-size: 20px;
+            left: 100px;
+            right: 100px;
+            text-align: center;
+            text-overflow: ellipsis;
+            overflow: hidden;
+            width: 50%;
+          }
+          .nav-header {
+            display: flex;
+          }
+          .hamburger {
+            display: inline-block;
+            vertical-align: middle;
+            height: 50px;
+            cursor: pointer;
+            margin: 0;
+            position: absolute;
+            top: 0;
+            left: 0;
+            padding: 12px;
+            z-index: 200;
+          }
+          .nav-sidebar ul {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: block;
+            padding-top: 50px;
+          }
+          .nav-sidebar li {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: inline-block;
+            position: relative;
+            font-size: 14;
+            color: var(--primary-text-color);
+            display: block;
+          }
+          .cover {
+            background: rgba(0, 0, 0, 0.5);
+            position: fixed;
+            top: 0;
+            bottom: 0;
+            left: 0;
+            right: 0;
+            overflow: none;
+            z-index: 199;
+          }
+          .hideOnDesktop {
+            display: block;
+          }
+          nav.hideOnMobile {
+            display: none;
+          }
+          .nav-sidebar .menu ul {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: block;
+            padding-top: 50px;
+          }
+          .nav-sidebar .menu li {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: inline-block;
+            position: relative;
+            font-size: 14;
+            color: var(--primary-text-color);
+            display: block;
+          }
+          .nav-sidebar .menu li a {
+            padding: 15px 20px;
+            font-size: 14;
+            outline: 0;
+            display: block;
+            color: var(--primary-text-color);
+            font-weight: 600;
+          }
+          .nav-sidebar .menu li.active ul.dropdown {
+            display: block;
+          }
+          .nav-sidebar .menu li ul.dropdown {
+            position: absolute;
+            display: none;
+            width: 100%;
+            box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+            padding-top: 0;
+            position: relative;
+          }
+          .nav-sidebar .menu li ul.dropdown li {
+            display: block;
+            list-style-type: none;
+          }
+          .nav-sidebar .menu li ul.dropdown li a {
+            padding: 15px 20px;
+            font-size: 15px;
+            display: block;
+            font-weight: 400;
+            border-bottom: none;
+            padding: 10px 10px 10px 30px;
+          }
+          .nav-sidebar .menu li ul.dropdown li:last-child a {
+            border-bottom: none;
+          }
+          .nav-sidebar .menu a {
+            text-decoration: none;
+          }
+          .nav-sidebar .menu li.active:first-child a {
+            border-radius: 3px 0 0 3px;
+            border-radius: 0;
+          }
+          .nav-sidebar .menu li ul.dropdown li.active:first-child a {
+            border-radius: 0;
+          }
+          .arrow-down {
+            position: absolute;
+            top: 10px;
+            right: 10px;
           }
         }
       `,
@@ -371,35 +609,134 @@
   }
 
   override render() {
+    return html` ${this.renderDesktop()} ${this.renderMobile()} `;
+  }
+
+  private renderDesktop() {
     return html`
-  <nav>
-    <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
-      <gr-endpoint-decorator name="header-title">
-        <div class="titleText"></div>
-      </gr-endpoint-decorator>
-    </a>
-    <ul class="links">
-      ${this.computeLinks(this.userLinks, this.adminLinks, this.topMenus).map(
-        linkGroup => this.renderLinkGroup(linkGroup)
-      )}
-    </ul>
-    <div class="rightItems">
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-small-banner"
-      ></gr-endpoint-decorator>
-      <gr-smart-search id="search"></gr-smart-search>
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-top-right"
-      ></gr-endpoint-decorator>
-      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
-        ${this.renderFeedback()}
-      </gr-endpoint-decorator>
-      </div>
-      ${this.renderAccount()}
-    </div>
-  </nav>
+      <nav class="hideOnMobile">
+        <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
+          <gr-endpoint-decorator name="header-title">
+            <div class="titleText"></div>
+          </gr-endpoint-decorator>
+        </a>
+        <ul class="links">
+          ${this.computeLinks(
+            this.userLinks,
+            this.adminLinks,
+            this.topMenus
+          ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+        </ul>
+        <div class="rightItems">
+          <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-small-banner"
+          ></gr-endpoint-decorator>
+          <gr-smart-search id="search"></gr-smart-search>
+          <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-top-right"
+          ></gr-endpoint-decorator>
+          <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+            ${this.renderFeedback()}
+          </gr-endpoint-decorator>
+          </div>
+          ${this.renderAccount()}
+        </div>
+      </nav>
+    `;
+  }
+
+  private renderMobile() {
+    const moreMenu: MainHeaderLink[] = [
+      {
+        name: this.registerText,
+        url: this.registerURL,
+      },
+      {
+        name: this.loginText,
+        url: this.loginUrl,
+      },
+    ];
+    if (!this.registerURL) {
+      moreMenu.shift();
+    }
+    if (this.feedbackURL) {
+      moreMenu.push({
+        name: 'Feedback',
+        url: this.feedbackURL,
+        external: true,
+        target: '_blank',
+      });
+    }
+
+    return html`
+      <nav class="hideOnDesktop">
+        <div class="nav-sidebar">
+          <ul class="menu">
+            ${this.computeLinks(
+              this.userLinks,
+              this.adminLinks,
+              this.topMenus
+            ).map(linkGroup => this.renderLinkGroupMobile(linkGroup))}
+          </ul>
+        </div>
+        <div class="nav-header">
+          <a
+            class="hamburger"
+            href=""
+            title="Hamburger"
+            aria-label="${!this.hamburgerClose ? 'Open' : 'Close'} hamburger"
+            role="button"
+            @click=${() => {
+              this.handleSidebar();
+            }}
+          >
+            ${!this.hamburgerClose
+              ? html`<gr-icon icon="menu" filled></gr-icon>`
+              : html`<gr-icon
+                  class="hamburger-open"
+                  icon="menu_open"
+                  filled
+                ></gr-icon>`}
+          </a>
+          <a
+            href=${`//${window.location.host}${getBaseUrl()}/`}
+            class="mobileTitle bigTitle"
+          >
+            <gr-endpoint-decorator name="header-mobile-title">
+              <div class="mobileTitleText"></div>
+            </gr-endpoint-decorator>
+          </a>
+          <div class="mobileRightItems">
+            <a
+              class="searchButton"
+              title="Search"
+              @click=${(e: Event) => {
+                this.onMobileSearchTap(e);
+              }}
+              role="button"
+              aria-label=${this.mobileSearchHidden
+                ? 'Show Searchbar'
+                : 'Hide Searchbar'}
+            >
+              <gr-icon icon="search" filled></gr-icon>
+            </a>
+            <gr-dropdown
+              class="moreMenu"
+              link=""
+              .items=${moreMenu}
+              horizontal-align="center"
+            >
+              <span class="linksTitle">
+                <gr-icon icon="more_horiz" filled></gr-icon>
+              </span>
+            </gr-dropdown>
+            ${this.renderAccountDropdown(true)}
+          </div>
+        </div>
+      </nav>
+      <div class="modelBackground" @click=${() => this.handleSidebar()}></div>
     `;
   }
 
@@ -420,6 +757,41 @@
     `;
   }
 
+  private renderLinkGroupMobile(linkGroup: MainHeaderLinkGroup) {
+    return html`
+      <li class="has-collapsible" @click=${this.handleCollapsible}>
+        <a class="main" href="" data-title=${linkGroup.title}
+          >${linkGroup.title}<gr-icon
+            icon="arrow_drop_down"
+            class="arrow-down"
+          ></gr-icon
+        ></a>
+        <ul class="dropdown">
+          ${linkGroup.links.map(link => this.renderLinkMobile(link))}
+        </ul>
+      </li>
+    `;
+  }
+
+  private renderLinkMobile(link: DropdownLink) {
+    return html`
+      <li tabindex="-1">
+        <span ?hidden=${!!link.url} tabindex="-1">${link.name}</span>
+        <a
+          class="itemAction"
+          href=${this.computeLinkURL(link)}
+          ?download=${!!link.download}
+          rel=${ifDefined(this.computeLinkRel(link) ?? undefined)}
+          target=${ifDefined(link.target ?? undefined)}
+          ?hidden=${!link.url}
+          tabindex="-1"
+          @click=${() => this.handleSidebar()}
+          >${link.name}</a
+        >
+      </li>
+    `;
+  }
+
   private renderFeedback() {
     if (!this.feedbackURL) return;
 
@@ -483,11 +855,14 @@
     `;
   }
 
-  private renderAccountDropdown() {
+  private renderAccountDropdown(showOnMobile?: boolean) {
     if (!this.account) return;
 
     return html`
-      <gr-account-dropdown .account=${this.account}></gr-account-dropdown>
+      <gr-account-dropdown
+        .account=${this.account}
+        ?showMobile=${showOnMobile}
+      ></gr-account-dropdown>
     `;
   }
 
@@ -533,7 +908,6 @@
       links.push({
         title: 'Documentation',
         links: docLinks,
-        class: 'hideOnMobile',
       });
     }
     links.push({
@@ -637,4 +1011,96 @@
     e.stopPropagation();
     fire(this, 'mobile-search', {});
   }
+
+  /**
+   * Build a URL for the given host and path. The base URL will be only added,
+   * if it is not already included in the path.
+   *
+   * TODO: Move to util handler to remove duplication.
+   * @return The scheme-relative URL.
+   */
+  private computeURLHelper(host: string, path: string) {
+    const base = path.startsWith(getBaseUrl()) ? '' : getBaseUrl();
+    return '//' + host + base + path;
+  }
+
+  /**
+   * Build a scheme-relative URL for the current host. Will include the base
+   * URL if one is present. Note: the URL will be scheme-relative but absolute
+   * with regard to the host.
+   *
+   * TODO: Move to util handler to remove duplication.
+   * @param path The path for the URL.
+   * @return The scheme-relative URL.
+   */
+  private computeRelativeURL(path: string) {
+    const host = window.location.host;
+    return this.computeURLHelper(host, path);
+  }
+
+  /**
+   * Compute the URL for a link object.
+   *
+   * Private but used in tests.
+   *
+   * TODO: Move to util handler to remove duplication.
+   */
+  private computeLinkURL(link: DropdownLink) {
+    if (typeof link.url === 'undefined') {
+      return '';
+    }
+    if (link.target || !link.url.startsWith('/')) {
+      return link.url;
+    }
+    return this.computeRelativeURL(link.url);
+  }
+
+  /**
+   * Compute the value for the rel attribute of an anchor for the given link
+   * object. If the link has a target value, then the rel must be "noopener"
+   * for security reasons.
+   * Private but used in tests.
+   *
+   * TODO: Move to util handler to remove duplication.
+   */
+  private computeLinkRel(link: DropdownLink) {
+    // Note: noopener takes precedence over external.
+    if (link.target) {
+      return REL_NOOPENER;
+    }
+    if (link.external) {
+      return REL_EXTERNAL;
+    }
+    return null;
+  }
+
+  private handleCollapsible(e: MouseEvent) {
+    const target = e.target as HTMLSpanElement;
+    if (target.hasAttribute('data-title')) {
+      if (target.parentElement?.classList.contains('active')) {
+        target.parentElement.classList.remove('active');
+      } else {
+        if (this.hasCollapsibleActive) {
+          this.hasCollapsibleActive.classList.remove('active');
+        }
+        target.parentElement?.classList.toggle('active');
+      }
+    }
+  }
+
+  private handleSidebar() {
+    this.navSidebar?.classList.toggle('visible');
+    if (!this.modelBackground?.classList.contains('cover')) {
+      if (document.getElementsByTagName('html')) {
+        document.getElementsByTagName('html')[0].style.overflow = 'hidden';
+      }
+    } else {
+      if (document.getElementsByTagName('html')) {
+        document.getElementsByTagName('html')[0].style.overflow = '';
+      }
+    }
+    this.modelBackground?.classList.toggle('cover');
+    this.hasCollapsibleActive?.classList.remove('active');
+    this.hamburgerClose = !this.hamburgerClose;
+  }
 }
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 40430fb..b4a0600 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
@@ -39,7 +39,7 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <nav>
+        <nav class="hideOnMobile">
           <a class="bigTitle" href="//localhost:9876/">
             <gr-endpoint-decorator name="header-title">
               <div class="titleText"></div>
@@ -51,7 +51,7 @@
                 <span class="linksTitle" id="Changes"> Changes </span>
               </gr-dropdown>
             </li>
-            <li class="hideOnMobile">
+            <li>
               <gr-dropdown down-arrow="" horizontal-align="left" link="">
                 <span class="linksTitle" id="Documentation">Documentation</span>
               </gr-dropdown>
@@ -101,6 +101,169 @@
             </a>
           </div>
         </nav>
+        <nav class="hideOnDesktop">
+          <div class="nav-sidebar">
+            <ul class="menu">
+              <li class="has-collapsible">
+                <a class="main" data-title="Changes" href="">
+                  Changes
+                  <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+                </a>
+                <ul class="dropdown">
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Open </span>
+                    <a
+                      class="itemAction"
+                      href="//localhost:9876/q/status:open+-is:wip"
+                      tabindex="-1"
+                    >
+                      Open
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Merged </span>
+                    <a
+                      class="itemAction"
+                      href="//localhost:9876/q/status:merged"
+                      tabindex="-1"
+                    >
+                      Merged
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Abandoned </span>
+                    <a
+                      class="itemAction"
+                      href="//localhost:9876/q/status:abandoned"
+                      tabindex="-1"
+                    >
+                      Abandoned
+                    </a>
+                  </li>
+                </ul>
+              </li>
+              <li class="has-collapsible">
+                <a class="main" data-title="Documentation" href="">
+                  Documentation
+                  <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+                </a>
+                <ul class="dropdown">
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Table of Contents </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/index.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Table of Contents
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Searching </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/user-search.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Searching
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Uploading </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/user-upload.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Uploading
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Access Control </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/access-control.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Access Control
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> REST API </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/rest-api.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      REST API
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Project Owner Guide </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/intro-project-owner.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Project Owner Guide
+                    </a>
+                  </li>
+                </ul>
+              </li>
+              <li class="has-collapsible">
+                <a class="main" data-title="Browse" href="">
+                  Browse
+                  <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+                </a>
+                <ul class="dropdown"></ul>
+              </li>
+            </ul>
+          </div>
+          <div class="nav-header">
+            <a
+              aria-label="Open hamburger"
+              class="hamburger"
+              href=""
+              role="button"
+              title="Hamburger"
+            >
+              <gr-icon filled="" icon="menu"> </gr-icon>
+            </a>
+            <a class="bigTitle mobileTitle" href="//localhost:9876/">
+              <gr-endpoint-decorator name="header-mobile-title">
+                <div class="mobileTitleText"></div>
+              </gr-endpoint-decorator>
+            </a>
+            <div class="mobileRightItems">
+              <a
+                aria-label="Hide Searchbar"
+                class="searchButton"
+                role="button"
+                title="Search"
+              >
+                <gr-icon filled="" icon="search"> </gr-icon>
+              </a>
+              <gr-dropdown class="moreMenu" horizontal-align="center" link="">
+                <span class="linksTitle">
+                  <gr-icon filled="" icon="more_horiz"> </gr-icon>
+                </span>
+              </gr-dropdown>
+            </div>
+          </div>
+        </nav>
+        <div class="modelBackground"></div>
       `
     );
   });
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 7d62735..977ac2e 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
@@ -41,6 +41,7 @@
 import {when} from 'lit/directives/when.js';
 import {Timing} from '../../../constants/reporting';
 import {changeModelToken} from '../../../models/change/change-model';
+import {getFileExtension} from '../../../utils/file-util';
 
 export interface FilePreview {
   filepath: string;
@@ -441,6 +442,9 @@
     this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
       method: 'apply-fix-dialog',
       description: this.fixSuggestions?.[0].description,
+      fileExtension: getFileExtension(
+        this.fixSuggestions?.[0].replacements?.[0].path ?? ''
+      ),
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index f228fb3..0a3392c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -3,7 +3,6 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Subscription} from 'rxjs';
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
@@ -17,6 +16,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {userModelToken} from '../../../models/user/user-model';
 import {ironAnnouncerRequestAvailability} from '../../polymer-util';
+import {subscribe} from '../../lit/subscription-controller';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends LitElement {
@@ -36,28 +36,18 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
-  private subscriptions: Subscription[] = [];
-
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      x => (this.mode = x)
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
     ironAnnouncerRequestAvailability();
-    this.subscriptions.push(
-      this.getBrowserModel().diffViewMode$.subscribe(
-        diffView => (this.mode = diffView)
-      )
-    );
-  }
-
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    super.disconnectedCallback();
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 15cfda6..cd19872 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -48,7 +48,7 @@
 
   setup(async () => {
     appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
-    stubElement('gr-account-dropdown', '_getTopContent');
+    stubElement('gr-account-dropdown', 'getTopContent');
     routerStartStub = sinon.stub(GrRouter.prototype, 'start');
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
     stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 9c99ae0..2220d2f 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -3,20 +3,23 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import {EmailInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
+import {deepClone} from '../../../utils/deep-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 @customElement('gr-email-editor')
 export class GrEmailEditor extends LitElement {
-  @property({type: Boolean}) hasUnsavedChanges = false;
+  @state() private originalEmails: EmailInfo[] = [];
 
   /* private but used in test */
   @state() emails: EmailInfo[] = [];
@@ -29,6 +32,21 @@
 
   readonly restApiService = getAppContext().restApiService;
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().emails$,
+      x => {
+        if (!x) return;
+        this.originalEmails = deepClone<EmailInfo[]>(x);
+        this.emails = deepClone<EmailInfo[]>(x);
+      }
+    );
+  }
+
   static override get styles() {
     return [
       sharedStyles,
@@ -82,24 +100,20 @@
     return html`<tr>
       <td class="emailColumn">${email.email}</td>
       <td class="preferredControl" @click=${this.handlePreferredControlClick}>
-        <iron-input
+        <!-- We have to use \`.checked\` rather then \`?checked\` as there
+              appears to be an issue when deleting, checked doesn't work correctly. -->
+        <input
           class="preferredRadio"
+          type="radio"
+          name="preferred"
+          .value=${email.email}
+          .checked=${email.preferred}
           @change=${this.handlePreferredChange}
-          .bindValue=${email.email}
-        >
-          <input
-            class="preferredRadio"
-            type="radio"
-            @change=${this.handlePreferredChange}
-            name="preferred"
-            ?checked=${email.preferred}
-          />
-        </iron-input>
+        />
       </td>
       <td>
         <gr-button
-          data-index=${index}
-          @click=${this.handleDeleteButton}
+          @click=${() => this.handleDeleteButton(index)}
           ?disabled=${this.checkPreferred(email.preferred)}
           class="remove-button"
           >Delete</gr-button
@@ -108,12 +122,6 @@
     </tr>`;
   }
 
-  loadData() {
-    return this.restApiService.getAccountEmails().then(emails => {
-      this.emails = emails ?? [];
-    });
-  }
-
   save() {
     const promises: Promise<unknown>[] = [];
 
@@ -127,24 +135,27 @@
       );
     }
 
-    return Promise.all(promises).then(() => {
+    return Promise.all(promises).then(async () => {
       this.emailsToRemove = [];
       this.newPreferred = '';
-      this.setHasUnsavedChanges(false);
+      await this.getUserModel().loadEmails(true);
+      this.setHasUnsavedChanges();
     });
   }
 
-  private handleDeleteButton(e: Event) {
-    const target = e.target;
-    if (!(target instanceof Element)) return;
-    const indexStr = target.getAttribute('data-index');
-    if (indexStr === null) return;
-    const index = Number(indexStr);
+  private handleDeleteButton(index: number) {
     const email = this.emails[index];
-    this.emailsToRemove = [...this.emailsToRemove, email];
+    // Don't add project to emailsToRemove if it wasn't in
+    // emails.
+    // We have to use JSON.stringify as we cloned the array
+    // so the reference is not the same.
+    const emails = this.emails.some(
+      x => JSON.stringify(email) === JSON.stringify(x)
+    );
+    if (emails) this.emailsToRemove.push(email);
     this.emails.splice(index, 1);
     this.requestUpdate();
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   private handlePreferredControlClick(e: Event) {
@@ -165,9 +176,10 @@
         this.emails[i].preferred = true;
         this.requestUpdate();
         this.newPreferred = preferred;
-        this.setHasUnsavedChanges(true);
+        this.setHasUnsavedChanges();
       } else if (this.emails[i].preferred) {
-        this.emails[i].preferred = false;
+        delete this.emails[i].preferred;
+        this.setHasUnsavedChanges();
         this.requestUpdate();
       }
     }
@@ -177,9 +189,11 @@
     return preferred ?? false;
   }
 
-  private setHasUnsavedChanges(value: boolean) {
-    this.hasUnsavedChanges = value;
-    fire(this, 'has-unsaved-changes-changed', {value});
+  private setHasUnsavedChanges() {
+    const hasUnsavedChanges =
+      JSON.stringify(this.originalEmails) !== JSON.stringify(this.emails) ||
+      this.emailsToRemove.length > 0;
+    fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 25c9b97..84ed8dc 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -11,6 +11,7 @@
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
+  let accountEmailStub: sinon.SinonStub;
 
   setup(async () => {
     const emails = [
@@ -19,13 +20,14 @@
       {email: 'email@three.com'},
     ];
 
-    stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
+    accountEmailStub = stubRestApi('getAccountEmails').returns(
+      Promise.resolve(emails)
+    );
 
     element = await fixture<GrEmailEditor>(
       html`<gr-email-editor></gr-email-editor>`
     );
 
-    await element.loadData();
     await element.updateComplete;
   });
 
@@ -45,20 +47,17 @@
             <tr>
               <td class="emailColumn">email@one.com</td>
               <td class="preferredControl">
-                <iron-input class="preferredRadio">
-                  <input
-                    class="preferredRadio"
-                    name="preferred"
-                    type="radio"
-                    value="email@one.com"
-                  />
-                </iron-input>
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@one.com"
+                />
               </td>
               <td>
                 <gr-button
                   aria-disabled="false"
                   class="remove-button"
-                  data-index="0"
                   role="button"
                   tabindex="0"
                 >
@@ -69,21 +68,17 @@
             <tr>
               <td class="emailColumn">email@two.com</td>
               <td class="preferredControl">
-                <iron-input class="preferredRadio">
-                  <input
-                    checked=""
-                    class="preferredRadio"
-                    name="preferred"
-                    type="radio"
-                    value="email@two.com"
-                  />
-                </iron-input>
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@two.com"
+                />
               </td>
               <td>
                 <gr-button
                   aria-disabled="true"
                   class="remove-button"
-                  data-index="1"
                   disabled=""
                   role="button"
                   tabindex="-1"
@@ -95,20 +90,17 @@
             <tr>
               <td class="emailColumn">email@three.com</td>
               <td class="preferredControl">
-                <iron-input class="preferredRadio">
-                  <input
-                    class="preferredRadio"
-                    name="preferred"
-                    type="radio"
-                    value="email@three.com"
-                  />
-                </iron-input>
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@three.com"
+                />
               </td>
               <td>
                 <gr-button
                   aria-disabled="false"
                   class="remove-button"
-                  data-index="2"
                   role="button"
                   tabindex="0"
                 >
@@ -123,6 +115,12 @@
   });
 
   test('renders', () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const rows = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll('tbody tr');
@@ -144,15 +142,21 @@
     );
     assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
   });
 
   test('edit preferred', () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const radios = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll<HTMLInputElement>('input[type=radio]');
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
@@ -162,7 +166,7 @@
 
     radios[0].click();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(hasUnsavedChangesSpy.called);
     assert.isOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
@@ -172,18 +176,24 @@
   });
 
   test('delete email', () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const buttons = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll('gr-button');
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
 
     buttons[2].click();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 1);
     assert.equal(element.emails.length, 2);
@@ -192,6 +202,12 @@
   });
 
   test('save changes', async () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const deleteEmailSpy = spyRestApi('deleteAccountEmail');
     const setPreferredSpy = spyRestApi('setPreferredAccountEmail');
 
@@ -199,7 +215,7 @@
       .shadowRoot!.querySelector('table')!
       .querySelectorAll('tbody tr');
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
@@ -208,16 +224,24 @@
     rows[0].querySelector('gr-button')!.click();
     rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(hasUnsavedChangesSpy.called);
+    assert.isTrue(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
     assert.equal(element.newPreferred, 'email@three.com');
     assert.equal(element.emailsToRemove.length, 1);
     assert.equal(element.emailsToRemove[0].email, 'email@one.com');
     assert.equal(element.emails.length, 2);
 
+    accountEmailStub.restore();
+
+    accountEmailStub = stubRestApi('getAccountEmails').returns(
+      Promise.resolve(element.emails)
+    );
+
     await element.save();
     assert.equal(deleteEmailSpy.callCount, 1);
     assert.equal(deleteEmailSpy.getCall(0).args[0], 'email@one.com');
     assert.isTrue(setPreferredSpy.called);
     assert.equal(setPreferredSpy.getCall(0).args[0], 'email@three.com');
+    assert.isFalse(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
   });
 });
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 985f9be..0b5f952 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
@@ -9,8 +9,12 @@
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -23,47 +27,48 @@
   @query('#generatedPasswordModal')
   generatedPasswordModal?: HTMLDialogElement;
 
-  @property({type: String})
+  @state()
   username?: string;
 
-  @property({type: String})
+  @state()
   generatedPassword?: string;
 
-  @property({type: String})
+  @state()
   status?: string;
 
-  @property({type: String})
+  @state()
   passwordUrl: string | null = null;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadData();
-  }
+  // Private but used in test
+  readonly getConfigModel = resolve(this, configModelToken);
 
-  loadData() {
-    const promises = [];
+  // Private but used in test
+  readonly getUserModel = resolve(this, userModelToken);
 
-    promises.push(
-      this.restApiService.getAccount().then(account => {
-        if (account) {
-          this.username = account.username;
-        }
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(info => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      info => {
         if (info) {
           this.passwordUrl = info.auth.http_password_url || null;
         } else {
           this.passwordUrl = null;
         }
-      })
+      }
     );
-
-    return Promise.all(promises);
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => {
+        if (account) {
+          this.username = account.username;
+        }
+      }
+    );
   }
 
   static override get styles() {
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 1abf8a7..d94638b 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
@@ -14,7 +14,7 @@
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html, assert} from '@open-wc/testing';
+import {fixture, html, assert, waitUntil} from '@open-wc/testing';
 
 suite('gr-http-password tests', () => {
   let element: GrHttpPassword;
@@ -29,7 +29,12 @@
     stubRestApi('getConfig').returns(Promise.resolve(config));
 
     element = await fixture(html`<gr-http-password></gr-http-password>`);
-    await element.loadData();
+    await waitUntil(
+      () => element.getUserModel().getState().account === account
+    );
+    await waitUntil(
+      () => element.getConfigModel().getState().serverConfig === config
+    );
     await waitEventLoop();
   });
 
@@ -121,7 +126,8 @@
 
   test('with http_password_url', async () => {
     config.auth.http_password_url = 'http://example.com/';
-    await element.loadData();
+    element.passwordUrl = config.auth.http_password_url;
+    await element.updateComplete;
     assert.isNotNull(element.passwordUrl);
     assert.equal(element.passwordUrl, config.auth.http_password_url);
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
index bb0f1e9..11c8e03 100644
--- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -57,6 +57,9 @@
   @query('#allowSuggestCodeWhileCommenting')
   allowSuggestCodeWhileCommenting?: HTMLInputElement;
 
+  @query('#allowAiCommentAutocompletion')
+  allowAiCommentAutocompletion?: HTMLInputElement;
+
   @query('#defaultBaseForMergesSelect')
   defaultBaseForMergesSelect!: HTMLInputElement;
 
@@ -121,18 +124,18 @@
       () => this.getConfigModel().docsBaseUrl$,
       docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
     );
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const suggestionsPlugins =
-          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+    if (
+      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
+      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+    ) {
+      subscribe(
+        this,
+        () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
         // We currently support results from only 1 provider.
-        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
-      });
+        suggestionsPlugins =>
+          (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+      );
+    }
   }
 
   static override get styles() {
@@ -284,6 +287,7 @@
           </section>
           ${this.renderBrowserNotifications()}
           ${this.renderGenerateSuggestionWhenCommenting()}
+          ${this.renderAiCommentAutocompletion()}
           ${this.renderDefaultBaseForMerges()}
           <section>
             <label class="title" for="relativeDateInChangeTable"
@@ -480,11 +484,7 @@
   // When the experiment is over, move this back to render(),
   // removing this function.
   private renderGenerateSuggestionWhenCommenting() {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
-      !this.suggestionsProvider
-    )
-      return nothing;
+    if (!this.suggestionsProvider) return nothing;
     return html`
       <section id="allowSuggestCodeWhileCommentingSection">
         <div class="title">
@@ -519,6 +519,37 @@
     `;
   }
 
+  // When the experiment is over, move this back to render(),
+  // removing this function.
+  private renderAiCommentAutocompletion() {
+    if (
+      !this.flagsService.isEnabled(KnownExperimentId.COMMENT_AUTOCOMPLETION) ||
+      !this.suggestionsProvider
+    )
+      return nothing;
+    return html`
+      <section id="allowAiCommentAutocompletionSection">
+        <div class="title">
+          <label for="allowAiCommentAutocompletion"
+            >AI suggested text completions while commenting</label
+          >
+        </div>
+        <span class="value">
+          <input
+            id="allowAiCommentAutocompletion"
+            type="checkbox"
+            ?checked=${this.prefs?.allow_autocompleting_comments}
+            @change=${() => {
+              this.prefs!.allow_autocompleting_comments =
+                this.allowAiCommentAutocompletion!.checked;
+              this.requestUpdate();
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
   // When this is fixed and can be re-enabled, move this back to render()
   // and remove function.
   private renderDefaultBaseForMerges() {
@@ -565,6 +596,8 @@
         Boolean(this.prefs?.allow_browser_notifications) ||
       Boolean(this.originalPrefs?.allow_suggest_code_while_commenting) !==
         Boolean(this.prefs?.allow_suggest_code_while_commenting) ||
+      Boolean(this.originalPrefs?.allow_autocompleting_comments) !==
+        Boolean(this.prefs?.allow_autocompleting_comments) ||
       this.originalPrefs?.default_base_for_merges !==
         this.prefs?.default_base_for_merges ||
       Boolean(this.originalPrefs?.relative_date_in_change_table) !==
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
deleted file mode 100644
index c7118f7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {grFormStyles} from '../../../styles/gr-form-styles';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-settings-item': GrSettingsItem;
-  }
-}
-
-@customElement('gr-settings-item')
-export class GrSettingsItem extends LitElement {
-  @property({type: String})
-  anchor?: string;
-
-  @property({type: String})
-  override title = '';
-
-  static override get styles() {
-    return [
-      grFormStyles,
-      css`
-        :host {
-          display: block;
-          margin-bottom: var(--spacing-xxl);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    const anchor = this.anchor ?? '';
-    return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
-  }
-}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
deleted file mode 100644
index 6c83bea..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-settings-menu-item': GrSettingsMenuItem;
-  }
-}
-
-@customElement('gr-settings-menu-item')
-export class GrSettingsMenuItem extends LitElement {
-  @property({type: String})
-  href?: string;
-
-  @property({type: String})
-  override title = '';
-
-  static override get styles() {
-    return [sharedStyles, pageNavStyles];
-  }
-
-  override render() {
-    const href = this.href ?? '';
-    return html` <div class="navStyles">
-      <li><a href=${href}>${this.title}</a></li>
-    </div>`;
-  }
-}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index fd7d67b..7626e14 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
@@ -38,7 +38,7 @@
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
-import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {fire, fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {LitElement, css, html} from 'lit';
@@ -190,7 +190,7 @@
     const message = await this.restApiService.confirmEmail(this.emailToken);
     if (message) fireAlert(this, message);
     this.getViewModel().clearToken();
-    await this.emailEditor.loadData();
+    await this.getUserModel().loadEmails(true);
   }
 
   override connectedCallback() {
@@ -230,8 +230,6 @@
       })
     );
 
-    promises.push(this.emailEditor.loadData());
-
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
       this.loading = false;
 
@@ -252,6 +250,7 @@
       css`
         :host {
           color: var(--primary-text-color);
+          overflow: auto;
         }
         h2 {
           font-family: var(--header-font-family);
@@ -340,6 +339,9 @@
               @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
                 this.accountInfoChanged = e.detail.value;
               }}
+              @account-detail-update=${() => {
+                fire(this, 'account-detail-update', {});
+              }}
             ></gr-account-info>
             <gr-button
               @click=${() => {
@@ -437,7 +439,6 @@
           </h2>
           <fieldset id="watchedProjects">
             <gr-watched-projects-editor
-              ?hasUnsavedChanges=${this.watchedProjectsChanged}
               @has-unsaved-changes-changed=${(
                 e: ValueChangedEvent<boolean>
               ) => {
@@ -463,7 +464,6 @@
           <fieldset id="email">
             <gr-email-editor
               id="emailEditor"
-              ?hasUnsavedChanges=${this.emailsChanged}
               @has-unsaved-changes-changed=${(
                 e: ValueChangedEvent<boolean>
               ) => {
@@ -471,8 +471,8 @@
               }}
             ></gr-email-editor>
             <gr-button
-              @click=${() => {
-                this.emailEditor.save();
+              @click=${async () => {
+                await this.emailEditor.save();
               }}
               ?disabled=${!this.emailsChanged}
               >Save changes</gr-button
@@ -605,7 +605,7 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
+    Promise.all([this.accountInfo.loadData()]);
   }
 
   // private but used in test
@@ -643,7 +643,7 @@
     if (!this.isNewEmailValid(this.newEmail)) return;
 
     this.addingEmail = true;
-    this.restApiService.addAccountEmail(this.newEmail).then(response => {
+    this.restApiService.addAccountEmail(this.newEmail).then(async response => {
       this.addingEmail = false;
 
       // If it was unsuccessful.
@@ -653,6 +653,8 @@
 
       this.lastSentVerificationEmail = this.newEmail;
       this.newEmail = '';
+
+      await this.getUserModel().loadEmails(true);
     });
   }
 
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 b6690b6..74bcca9 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
@@ -331,12 +331,6 @@
     assert.isNotOk(element.lastSentVerificationEmail);
   });
 
-  test('emails are loaded without emailToken', () => {
-    const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
-    element.firstUpdated();
-    assert.isTrue(emailEditorLoadDataStub.calledOnce);
-  });
-
   test('handleSaveChangeTable', () => {
     let newColumns = ['Owner', 'Project', 'Branch'];
     element.localChangeTableColumns = newColumns.slice(0);
@@ -387,10 +381,8 @@
       value: string | PromiseLike<string | null> | null
     ) => void;
     let confirmEmailStub: sinon.SinonStub;
-    let emailEditorLoadDataStub: sinon.SinonStub;
 
     setup(() => {
-      emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
       confirmEmailStub = stubRestApi('confirmEmail').returns(
         new Promise(resolve => {
           resolveConfirm = resolve;
@@ -406,16 +398,6 @@
       assert.isTrue(confirmEmailStub.calledWith('foo'));
     });
 
-    test('emails are not loaded initially', () => {
-      assert.isFalse(emailEditorLoadDataStub.called);
-    });
-
-    test('user emails are loaded after email confirmed', async () => {
-      resolveConfirm('bar');
-      await element._testOnly_loadingPromise;
-      assert.isTrue(emailEditorLoadDataStub.calledOnce);
-    });
-
     test('show-alert is fired when email is confirmed', async () => {
       const dispatchEventSpy = sinon.spy(element, 'dispatchEvent');
       resolveConfirm('bar');
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 f0f0f2f..583798c 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
@@ -6,7 +6,7 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {
   AutocompleteQuery,
   GrAutocomplete,
@@ -22,6 +22,7 @@
 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';
+import {notDeepEqual} from '../../../utils/deep-util';
 
 type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
 
@@ -43,13 +44,13 @@
   @query('#newProject')
   newProject?: GrAutocomplete;
 
-  @property({type: Boolean})
-  hasUnsavedChanges = false;
+  @state()
+  originalProjects?: ProjectWatchInfo[];
 
-  @property({type: Array})
+  @state()
   projects?: ProjectWatchInfo[];
 
-  @property({type: Array})
+  @state()
   projectsToRemove: ProjectWatchInfo[] = [];
 
   private readonly query: AutocompleteQuery = input =>
@@ -163,7 +164,8 @@
 
   loadData() {
     return this.restApiService.getWatchedProjects().then(projs => {
-      this.projects = projs;
+      this.originalProjects = projs;
+      this.projects = projs ? [...projs] : [];
     });
   }
 
@@ -186,9 +188,10 @@
         }
       })
       .then(projects => {
-        this.projects = projects;
+        this.originalProjects = projects;
+        this.projects = projects ? [...projects] : [];
         this.projectsToRemove = [];
-        this.setHasUnsavedChanges(false);
+        this.setHasUnsavedChanges();
       });
   }
 
@@ -206,13 +209,16 @@
   }
 
   private handleRemoveProject(project: ProjectWatchInfo) {
-    if (!this.projects) return;
+    if (!this.projects || !this.originalProjects) return;
     const index = this.projects.indexOf(project);
     if (index < 0) return;
     this.projects.splice(index, 1);
-    this.projectsToRemove.push(project);
+    // Don't add project to projectsToRemove if it wasn't in
+    // originalProjects.
+    if (this.originalProjects.includes(project))
+      this.projectsToRemove.push(project);
     this.requestUpdate();
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   // private but used in tests.
@@ -288,7 +294,7 @@
 
     this.newProject.clear();
     this.newFilter.value = '';
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   private handleCheckboxChange(
@@ -300,7 +306,7 @@
     const checked = el.checked;
     project[key] = !!checked;
     this.requestUpdate();
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   private handleNotifCellClick(e: Event) {
@@ -311,9 +317,11 @@
     }
   }
 
-  private setHasUnsavedChanges(value: boolean) {
-    this.hasUnsavedChanges = value;
-    fire(this, 'has-unsaved-changes-changed', {value});
+  private setHasUnsavedChanges() {
+    const hasUnsavedChanges =
+      notDeepEqual(this.originalProjects, this.projects) ||
+      this.projectsToRemove.length > 0;
+    fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
   }
 
   isFilterDefined(filter: string | null | undefined) {
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 1db484c..704cecf 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
@@ -16,7 +16,7 @@
   isServiceUser,
 } from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {LitElement, css, html, TemplateResult} from 'lit';
@@ -25,17 +25,17 @@
 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 {accountsModelToken} from '../../../models/accounts/accounts-model';
 import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
   @property({type: Object})
   account?: AccountInfo;
 
-  @property({type: Object})
-  _selfAccount?: AccountInfo;
-
   /**
    * Optional ChangeInfo object, typically comes from the change page or
    * from a row in a list of search results. This is needed for some change
@@ -71,9 +71,6 @@
   @property({type: Boolean})
   hideAvatar = false;
 
-  @state()
-  _config?: ServerInfo;
-
   @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
@@ -98,12 +95,24 @@
   @property({type: Boolean, reflect: true})
   avatarShown = false;
 
+  // Private but used in tests.
+  @state()
+  selfAccount?: AccountInfo;
+
+  // Private but used in tests.
+  @state()
+  config?: ServerInfo;
+
   readonly reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getAccountsModel = resolve(this, accountsModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
   static override get styles() {
     return [
       css`
@@ -193,32 +202,39 @@
     ];
   }
 
-  override async updated() {
-    assertIsDefined(this.account, 'account');
+  override updated() {
+    this.computeDetailedAccount();
+  }
+
+  private async computeDetailedAccount() {
+    if (!this.account) return;
+    // If this.account is already a detailed object, then there is no need to fill it.
     if (isDetailedAccount(this.account)) return;
     const account = await this.getAccountsModel().fillDetails(this.account);
-    if (!isDetailedAccount(account)) return;
-    // AccountInfo returned by fillDetails has the email property set
-    // to the primary email of the account. This poses a problem in
-    // cases where a secondary email is used as the committer or author
-    // email. Therefore, only fill in the *missing* properties.
     if (
       account &&
+      // If we were not able to get a detailed object, then there is no point in updating the
+      // account.
+      isDetailedAccount(account) &&
       account !== this.account &&
       account._account_id === this.account._account_id
     ) {
+      // AccountInfo returned by fillDetails has the email property set
+      // to the primary email of the account. This poses a problem in
+      // cases where a secondary email is used as the committer or author
+      // email. Therefore, only fill in the *missing* properties.
       this.account = {...account, ...this.account};
     }
   }
 
   override render() {
-    const {account, change, highlightAttention, forceAttention, _config} = this;
+    const {account, change, highlightAttention, forceAttention, config} = this;
     if (!account) return;
     this.attentionIconShown =
       forceAttention ||
       this.hasUnforcedAttention(highlightAttention, account, change);
     this.deselected = !this.selected;
-    const hasAvatars = !!_config?.plugin?.has_avatars;
+    const hasAvatars = !!config?.plugin?.has_avatars;
     this.avatarShown = !this.hideAvatar && hasAvatars;
 
     return html`
@@ -238,7 +254,7 @@
                 account,
                 change,
                 false,
-                this._selfAccount
+                this.selfAccount
               )}
               title=${this.computeAttentionIconTitle(
                 highlightAttention,
@@ -246,7 +262,7 @@
                 change,
                 forceAttention,
                 this.selected,
-                this._selfAccount
+                this.selfAccount
               )}
             >
               <gr-button
@@ -259,7 +275,7 @@
                   account,
                   change,
                   this.selected,
-                  this._selfAccount
+                  this.selfAccount
                 )}
               >
                 <div>
@@ -291,7 +307,7 @@
               class="name"
               part="gr-account-label-text"
             >
-              ${this.computeName(account, this.firstName, this._config)}
+              ${this.computeName(account, this.firstName, this.config)}
             </span>
             ${this.renderAccountStatusPlugins()}
           </span>
@@ -302,12 +318,16 @@
 
   constructor() {
     super();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      x => (this.config = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
       if (this.change) this.change = {...this.change};
@@ -386,7 +406,7 @@
 
     // 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 = getRemovedByIconClickReason(this._selfAccount, this._config);
+    const reason = getRemovedByIconClickReason(this.selfAccount, this.config);
     if (this.change.attention_set)
       delete this.change.attention_set[this.account._account_id];
     // For re-evaluation of everything that depends on 'change'.
@@ -412,7 +432,7 @@
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = this._selfAccount?._account_id || -1;
+    const selfId = this.selfAccount?._account_id || -1;
     const reviewers =
       this.change && this.change.reviewers && this.change.reviewers.REVIEWER
         ? [...this.change.reviewers.REVIEWER]
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 71f8391..8b49cdd 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
@@ -154,11 +154,11 @@
   suite('attention set', () => {
     setup(async () => {
       element.highlightAttention = true;
-      element._config = {
+      element.config = {
         ...createServerInfo(),
         user: {anonymous_coward_name: 'Anonymous Coward'},
       };
-      element._selfAccount = kermit;
+      element.selfAccount = kermit;
       element.account = {
         ...createAccountDetailWithIdNameAndEmail(42),
         name: 'ernie',
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 863ee90..b445f75 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
@@ -17,7 +17,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
 import {isDefined} from '../../../types/types';
 import {when} from 'lit/directives/when.js';
 
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 45297f8..c20994c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -10,7 +10,7 @@
 import '../gr-dialog/gr-dialog';
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icon/gr-icon';
-import '../gr-textarea/gr-textarea';
+import '../gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
@@ -20,7 +20,7 @@
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {provide, resolve} from '../../../models/dependency';
-import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
 import {
   AccountDetailInfo,
   DraftInfo,
@@ -79,8 +79,12 @@
   commentModelToken,
 } from '../gr-comment-model/gr-comment-model';
 import {formStyles} from '../../../styles/form-styles';
-import {Interaction} from '../../../constants/reporting';
-import {Suggestion, SuggestionsProvider} from '../../../api/suggestions';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {
+  AutocompleteCommentResponse,
+  Suggestion,
+  SuggestionsProvider,
+} from '../../../api/suggestions';
 import {when} from 'lit/directives/when.js';
 import {getDocUrl} from '../../../utils/url-util';
 import {configModelToken} from '../../../models/config/config-model';
@@ -89,10 +93,17 @@
 import {deepEqual} from '../../../utils/deep-util';
 import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {waitUntil} from '../../../utils/async-util';
+import {
+  AutocompleteCache,
+  AutocompletionContext,
+} from '../../../utils/autocomplete-cache';
+import {HintAppliedEventDetail, HintShownEventDetail} from '../../../api/embed';
+import {levenshteinDistance} from '../../../utils/string-util';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
 export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 500;
+export const AUTOCOMPLETE_DEBOUNCE_DELAY_MS = 200;
 export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
   'enableGenerateSuggestionStorageKeyForCommentWithId-';
 
@@ -143,7 +154,7 @@
    */
 
   @query('#editTextarea')
-  textarea?: GrTextarea;
+  textarea?: GrSuggestionTextarea;
 
   @query('#container')
   container?: HTMLElement;
@@ -219,6 +230,19 @@
   @state()
   messageText = '';
 
+  /**
+   * An hint for autocompleting the comment message from plugin suggestion
+   * providers.
+   */
+  @state() autocompleteHint?: AutocompletionContext;
+
+  private autocompleteAcceptedHints: string[] = [];
+
+  /** Based on user preferences. */
+  @state() autocompleteEnabled = true;
+
+  readonly autocompleteCache = new AutocompleteCache();
+
   /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
   @state()
   unresolved = true;
@@ -300,6 +324,12 @@
   private generateSuggestionTrigger$ = new Subject();
 
   /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call autocompleteComment().
+   */
+  private autocompleteTrigger$ = new Subject();
+
+  /**
    * Set to the content of DraftInfo when entering editing mode.
    * Only used for "Cancel".
    */
@@ -392,6 +422,23 @@
       () => this.getConfigModel().docsBaseUrl$,
       docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
     );
+    subscribe(
+      this,
+      () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
+      // We currently support results from only 1 provider.
+      suggestionsPlugins =>
+        (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+    );
+    subscribe(
+      this,
+      () =>
+        this.autocompleteTrigger$.pipe(
+          debounceTime(AUTOCOMPLETE_DEBOUNCE_DELAY_MS)
+        ),
+      () => {
+        this.autocompleteComment();
+      }
+    );
     if (
       this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
       this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
@@ -410,6 +457,7 @@
         this,
         () => this.getUserModel().preferences$,
         prefs => {
+          this.autocompleteEnabled = !!prefs.allow_autocompleting_comments;
           if (
             this.generateSuggestion !==
             !!prefs.allow_suggest_code_while_commenting
@@ -424,15 +472,6 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const suggestionsPlugins =
-          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
-        // We currently support results from only 1 provider.
-        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
-      });
-
     if (this.comment?.id) {
       const generateSuggestionStoredContent =
         this.getStorage().getEditableContentItem(
@@ -650,6 +689,10 @@
           /* Making up for the 2px reduced height above. */
           top: 1px;
         }
+        gr-suggestion-diff-preview,
+        gr-fix-suggestions {
+          margin-top: var(--spacing-s);
+        }
       `,
     ];
   }
@@ -864,7 +907,7 @@
   private renderEditingTextarea() {
     if (!this.editing || this.collapsed) return;
     return html`
-      <gr-textarea
+      <gr-suggestion-textarea
         id="editTextarea"
         class="editMessage"
         autocomplete="on"
@@ -872,19 +915,97 @@
         rows="4"
         .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
-        @text-changed=${(e: ValueChangedEvent) => {
-          // TODO: This is causing a re-render of <gr-comment> on every key
-          // press. Try to avoid always setting `this.messageText` or at least
-          // debounce it. Most of the code can just inspect the current value
-          // of the textare instead of needing a dedicated property.
-          this.messageText = e.detail.value;
-          this.autoSaveTrigger$.next();
-          this.generateSuggestionTrigger$.next();
-        }}
-      ></gr-textarea>
+        autocompleteHint=${this.autocompleteHint?.commentCompletion ?? ''}
+        @text-changed=${this.handleTextChanged}
+        @hintShown=${this.handleHintShown}
+        @hintApplied=${this.handleHintApplied}
+      ></gr-suggestion-textarea>
     `;
   }
 
+  private handleHintShown(e: CustomEvent<HintShownEventDetail>) {
+    const context = this.autocompleteCache.get(e.detail.oldValue);
+    if (context?.commentCompletion !== e.detail.hint) return;
+
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SUGGESTION_SHOWN,
+      context
+    );
+  }
+
+  private handleHintApplied(e: CustomEvent<HintAppliedEventDetail>) {
+    const context = this.autocompleteCache.get(e.detail.oldValue);
+    if (context?.commentCompletion !== e.detail.hint) return;
+
+    this.autocompleteAcceptedHints.push(e.detail.hint);
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SUGGESTION_ACCEPTED,
+      context
+    );
+  }
+
+  private reportHintInteractionSaved() {
+    if (this.autocompleteAcceptedHints.length === 0) return;
+    const content = this.messageText.trimEnd();
+    const acceptedHintsConcatenated = this.autocompleteAcceptedHints.join('');
+    const numExtraCharacters =
+      content.length - acceptedHintsConcatenated.length;
+    let distance = levenshteinDistance(acceptedHintsConcatenated, content);
+    if (numExtraCharacters > 0) {
+      distance -= numExtraCharacters;
+    }
+    const context = {
+      ...this.createAutocompletionBaseContext(),
+      similarCharacters: acceptedHintsConcatenated.length - distance,
+      maxSimilarCharacters: acceptedHintsConcatenated.length,
+      acceptedSuggestionsCount: this.autocompleteAcceptedHints.length,
+      totalAcceptedCharacters: acceptedHintsConcatenated.length,
+      savedDraftLength: content.length,
+    };
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SAVE_DRAFT,
+      context
+    );
+  }
+
+  private reportHintInteraction(
+    interaction: Interaction,
+    context: Partial<AutocompletionContext>
+  ) {
+    context = {
+      ...context,
+      draftContent: '[REDACTED]',
+      commentCompletion: '[REDACTED]',
+    };
+    this.reporting.reportInteraction(interaction, context);
+  }
+
+  private handleTextChanged(e: ValueChangedEvent) {
+    const oldValue = this.messageText;
+    const newValue = e.detail.value;
+    if (oldValue === newValue) return;
+    // TODO: This is causing a re-render of <gr-comment> on every key
+    // press. Try to avoid always setting `this.messageText` or at least
+    // debounce it. Most of the code can just inspect the current value
+    // of the textare instead of needing a dedicated property.
+    this.messageText = newValue;
+
+    this.handleTextChangedForAutocomplete();
+    this.autoSaveTrigger$.next();
+    this.generateSuggestionTrigger$.next();
+  }
+
+  // visible for testing
+  handleTextChangedForAutocomplete() {
+    const cachedHint = this.autocompleteCache.get(this.messageText);
+    if (cachedHint) {
+      this.autocompleteHint = cachedHint;
+    } else {
+      this.autocompleteHint = undefined;
+      this.autocompleteTrigger$.next();
+    }
+  }
+
   private renderCommentMessage() {
     if (this.collapsed || this.editing) return;
 
@@ -902,6 +1023,7 @@
   private renderCopyLinkIcon() {
     // Only show the icon when the thread contains a published comment.
     if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
+    if (this.editing) return;
     return html`
       <gr-icon
         icon="link"
@@ -945,6 +1067,7 @@
         ${this.renderDiscardButton()} ${this.renderEditButton()}
         ${this.renderCancelButton()} ${this.renderSaveButton()}
         ${this.renderCopyLinkIcon()}
+        <gr-endpoint-slot name="draft-actions-end"></gr-endpoint-slot>
       </div>
     `;
   }
@@ -1216,6 +1339,7 @@
     this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
       uuid: this.generatedSuggestionId,
       type: 'suggest-code',
+      commentId: this.comment.id,
       response: suggestionResponse.responseCode,
       numSuggestions: suggestionResponse.suggestions.length,
       hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
@@ -1267,8 +1391,10 @@
     this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
       uuid: this.generatedSuggestionId,
       type: 'suggest-fix',
+      commentId: this.comment.id,
       response: suggestionResponse.responseCode,
       numSuggestions: suggestionResponse.fix_suggestions.length,
+      logProbability: suggestionResponse.fix_suggestions?.[0].log_probability,
     });
     const suggestion = suggestionResponse.fix_suggestions?.[0];
     if (!suggestion?.replacements || suggestion.replacements.length === 0) {
@@ -1284,6 +1410,82 @@
     }
   }
 
+  private async autocompleteComment() {
+    const enabled = this.flagsService.isEnabled(
+      KnownExperimentId.COMMENT_AUTOCOMPLETION
+    );
+    const suggestionsProvider = this.suggestionsProvider;
+    const change = this.getChangeModel().getChange();
+    if (
+      !enabled ||
+      !this.autocompleteEnabled ||
+      !suggestionsProvider?.autocompleteComment ||
+      !change ||
+      !this.comment?.patch_set ||
+      !this.comment.path ||
+      this.messageText.length === 0
+    ) {
+      return;
+    }
+    const commentText = this.messageText;
+    this.reporting.time(Timing.COMMENT_COMPLETION);
+    const response = await suggestionsProvider.autocompleteComment({
+      id: id(this.comment),
+      commentText,
+      changeInfo: change as ChangeInfo,
+      patchsetNumber: this.comment?.patch_set,
+      filePath: this.comment.path,
+      range: this.comment.range,
+      lineNumber: this.comment.line,
+    });
+    const elapsed = this.reporting.timeEnd(Timing.COMMENT_COMPLETION);
+    const context = this.createAutocompletionContext(
+      commentText,
+      response,
+      elapsed
+    );
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SUGGESTION_FETCHED,
+      {...context, hasDraftChanged: this.messageText !== commentText}
+    );
+    if (!response?.completion) return;
+    // Note that we are setting the cache value for `commentText` and getting the value
+    // for `this.messageText`.
+    this.autocompleteCache.set(context);
+    this.autocompleteHint = this.autocompleteCache.get(this.messageText);
+  }
+
+  private createAutocompletionBaseContext(): Partial<AutocompletionContext> {
+    return {
+      commentId: id(this.comment!),
+      commentNumber: this.comments?.length ?? 0,
+      filePath: this.comment!.path,
+      fileExtension: getFileExtension(this.comment!.path ?? ''),
+    };
+  }
+
+  private createAutocompletionContext(
+    draftContent: string,
+    response: AutocompleteCommentResponse,
+    requestDurationMs: number
+  ): AutocompletionContext {
+    const commentCompletion = response.completion ?? '';
+    return {
+      ...this.createAutocompletionBaseContext(),
+
+      draftContent,
+      draftContentLength: draftContent.length,
+      commentCompletion,
+      commentCompletionLength: commentCompletion.length,
+
+      isFullCommentPrediction: draftContent.length === 0,
+      draftInSyncWithSuggestionLength: 0,
+      modelVersion: response.modelVersion ?? '',
+      outcome: response.outcome,
+      requestDurationMs,
+    };
+  }
+
   private renderRobotActions() {
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
@@ -1710,6 +1912,7 @@
     if (this.isFixSuggestionChanged()) {
       draft.fix_suggestions = this.getFixSuggestions();
     }
+    this.reportHintInteractionSaved();
     return this.getCommentsModel().saveDraft(draft, options.showToast);
   }
 
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 08e5389..444a43f 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
@@ -342,6 +342,8 @@
                     >
                       Edit
                     </gr-button>
+                    <gr-endpoint-slot name="draft-actions-end">
+                    </gr-endpoint-slot>
                   </div>
                 </div>
               </div>
@@ -406,15 +408,16 @@
                 </div>
               </div>
               <div class="body">
-                <gr-textarea
+                <gr-suggestion-textarea
                   autocomplete="on"
+                  autocompletehint=""
                   class="code editMessage"
                   code=""
                   id="editTextarea"
                   rows="4"
                   text="This is the test comment message."
                 >
-                </gr-textarea>
+                </gr-suggestion-textarea>
                 <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
                 <div class="actions">
                   <div class="leftActions">
@@ -444,6 +447,8 @@
                     >
                       Save
                     </gr-button>
+                    <gr-endpoint-slot name="draft-actions-end">
+                    </gr-endpoint-slot>
                   </div>
                 </div>
               </div>
@@ -592,7 +597,7 @@
       element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
       await element.updateComplete;
-      pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
+      pressKey(element.textarea!, 's', Modifier.CTRL_KEY);
       assert.isTrue(spy.called);
     });
 
@@ -602,11 +607,7 @@
         element.messageText = 'is that the horse from horsing around??';
         element.editing = true;
         await element.updateComplete;
-        pressKey(
-          element.textarea!.textarea!.textarea,
-          Key.ENTER,
-          Modifier.CTRL_KEY
-        );
+        pressKey(element.textarea!, Key.ENTER, Modifier.CTRL_KEY);
         assert.isTrue(spy.called);
       });
       test('propagates on patchset comment', async () => {
@@ -895,6 +896,44 @@
     });
   });
 
+  suite('handleTextChangedForAutocomplete', () => {
+    test('foo -> foo with asdf', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foo';
+      element.handleTextChangedForAutocomplete();
+      assert.equal(element.autocompleteHint.commentCompletion, 'asdf');
+    });
+
+    test('foo -> bar with asdf', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'bar';
+      element.handleTextChangedForAutocomplete();
+      assert.isUndefined(element.autocompleteHint);
+    });
+
+    test('foo -> foofoo with asdf', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foofoo';
+      element.handleTextChangedForAutocomplete();
+      assert.isUndefined(element.autocompleteHint);
+    });
+
+    test('foo -> foofoo with foomore', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'foomore'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foofoo';
+      element.handleTextChangedForAutocomplete();
+      assert.equal(element.autocompleteHint.commentCompletion, 'more');
+    });
+  });
+
   suite('suggest edit', () => {
     let element: GrComment;
     setup(async () => {
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 babe5e2..2ab3dd5 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
@@ -3,11 +3,9 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Subscription} from 'rxjs';
 import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
 import {paperStyles} from '../../../styles/gr-paper-styles';
@@ -18,6 +16,7 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 declare global {
   interface HTMLElementEventMap {
@@ -55,37 +54,29 @@
   @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
-  private readonly restApiService = getAppContext().restApiService;
-
   // Private but used in tests.
   readonly getUserModel = resolve(this, userModelToken);
 
-  private subscriptions: Subscription[] = [];
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.subscriptions.push(
-      this.getUserModel().preferences$.subscribe(prefs => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
           fire(this, 'selected-scheme-changed', {value: this.selectedScheme});
         }
-      })
+      }
     );
   }
 
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    super.disconnectedCallback();
-  }
-
   static override get styles() {
     return [
       paperStyles,
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 7cb1d00..195eeb6 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -87,6 +87,9 @@
   static override get styles() {
     return [
       css`
+        :host {
+          display: block;
+        }
         .header {
           background-color: var(--background-color-primary);
           border: 1px solid var(--border-color);
@@ -170,7 +173,11 @@
         };
       }),
       patchNum: this.comment.patch_set,
-      onCloseFixPreviewCallbacks: [],
+      onCloseFixPreviewCallbacks: [
+        fixApplied => {
+          if (fixApplied) fire(this, 'apply-user-suggestion', {});
+        },
+      ],
     };
     fire(this, 'open-fix-preview', eventDetail);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 8314912..e1d4f89 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -30,6 +30,7 @@
 import {fire} from '../../../utils/event-util';
 import {Interaction, Timing} from '../../../constants/reporting';
 import {createChangeUrl} from '../../../models/views/change';
+import {getFileExtension} from '../../../utils/file-util';
 
 declare global {
   interface HTMLElementEventMap {
@@ -151,9 +152,16 @@
   static override get styles() {
     return [
       css`
+        :host {
+          display: block;
+        }
         .buttons {
           text-align: right;
         }
+        .diff-container {
+          border: 1px solid var(--border-color);
+          border-top: none;
+        }
         code {
           max-width: var(--gr-formatted-text-prose-max-width, none);
           background-color: var(--background-color-secondary);
@@ -220,14 +228,16 @@
     if (!anyLineTooLong(diff)) {
       this.syntaxLayer.process(diff);
     }
-    return html`<gr-diff
-      .prefs=${this.overridePartialDiffPrefs()}
-      .path=${this.preview.filepath}
-      .diff=${diff}
-      .layers=${this.layers}
-      .renderPrefs=${this.renderPrefs}
-      .viewMode=${DiffViewMode.UNIFIED}
-    ></gr-diff>`;
+    return html`<div class="diff-container">
+      <gr-diff
+        .prefs=${this.overridePartialDiffPrefs()}
+        .path=${this.preview.filepath}
+        .diff=${diff}
+        .layers=${this.layers}
+        .renderPrefs=${this.renderPrefs}
+        .viewMode=${DiffViewMode.UNIFIED}
+      ></gr-diff>
+    </div>`;
   }
 
   private async fetchFixPreview() {
@@ -255,6 +265,7 @@
     });
     this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
       uuid: this.uuid,
+      commentId: this.comment?.id ?? '',
     });
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
@@ -287,6 +298,7 @@
     });
     this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
       uuid: this.uuid,
+      commentId: this.comment?.id ?? '',
     });
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
@@ -343,6 +355,10 @@
     this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
       method: '1-click',
       description: fixSuggestion.description,
+      fileExtension: getFileExtension(
+        fixSuggestion?.replacements?.[0].path ?? ''
+      ),
+      commentId: this.comment?.id ?? '',
     });
     if (res?.ok) {
       this.getNavigation().setUrl(
@@ -371,6 +387,7 @@
     if (!this.suggestion) return;
     this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_ADDED, {
       uuid: this.uuid,
+      commentId: this.comment?.id ?? '',
     });
     fire(this, 'add-generated-suggestion', {code: this.suggestion});
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index 86be868..2630aad 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -103,10 +103,12 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-diff
-          class="disable-context-control-buttons hide-line-length-indicator"
-        >
-        </gr-diff>
+        <div class="diff-container">
+          <gr-diff
+            class="disable-context-control-buttons hide-line-length-indicator"
+          >
+          </gr-diff>
+        </div>
       `,
       {ignoreAttributes: ['style']}
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
similarity index 78%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 7f70911..a8a779d 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -7,6 +7,7 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
+import '../../../embed/gr-textarea';
 import {getAppContext} from '../../../services/app-context';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {
@@ -17,7 +18,7 @@
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html} from 'lit';
+import {LitElement, TemplateResult, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
@@ -31,6 +32,8 @@
 import {getAccountDisplayName} from '../../../utils/display-name-util';
 import {configModelToken} from '../../../models/config/config-model';
 import {formStyles} from '../../../styles/form-styles';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {GrTextarea} from '../../../embed/gr-textarea';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -58,6 +61,121 @@
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
+/** Allows us to swap out <iron-autogrow-textare> for <gr-textarea>. */
+abstract class TextAreaWrapper {
+  constructor(readonly el: GrSuggestionTextarea) {}
+
+  abstract render(): TemplateResult;
+
+  abstract isFocused(): boolean;
+
+  abstract focus(): void;
+
+  abstract putCursorAtEnd(): void;
+
+  abstract getCursorPosition(): number;
+
+  abstract setCursorPosition(pos: number): void;
+}
+
+class IronWrapper extends TextAreaWrapper {
+  override render() {
+    return html`
+      <iron-autogrow-textarea
+        id="textarea"
+        class=${classMap({noBorder: this.el.hideBorder})}
+        .autocomplete=${this.el.autocomplete}
+        .placeholder=${this.el.placeholder}
+        ?disabled=${this.el.disabled}
+        .rows=${this.el.rows}
+        .maxRows=${this.el.maxRows}
+        .value=${this.el.text}
+        @value-changed=${(e: ValueChangedEvent) => {
+          this.el.text = e.detail.value;
+        }}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  getIronTextarea(): IronAutogrowTextareaElement | undefined {
+    return this.el.textarea as IronAutogrowTextareaElement | undefined;
+  }
+
+  private getNativeTextarea(): HTMLTextAreaElement | undefined {
+    return this.getIronTextarea()?.textarea;
+  }
+
+  isFocused() {
+    return !!this.getIronTextarea()?.focused;
+  }
+
+  focus() {
+    this.getNativeTextarea()?.focus();
+  }
+
+  putCursorAtEnd() {
+    const textarea = this.getNativeTextarea();
+    if (!textarea) return;
+    const length = this.el.text.length;
+    textarea.selectionStart = length;
+    textarea.selectionEnd = length;
+    textarea.focus();
+  }
+
+  getCursorPosition(): number {
+    return this.getNativeTextarea()?.selectionStart ?? -1;
+  }
+
+  setCursorPosition(pos: number) {
+    const textarea = this.getNativeTextarea();
+    if (!textarea) return;
+    textarea.selectionStart = pos;
+    textarea.selectionEnd = pos;
+  }
+}
+
+class GrWrapper extends TextAreaWrapper {
+  override render() {
+    return html`<gr-textarea
+      id="textarea"
+      putCursorAtEndOnFocus
+      class=${classMap({noBorder: this.el.hideBorder})}
+      .placeholder=${this.el.placeholder}
+      ?disabled=${this.el.disabled}
+      .value=${this.el.text}
+      .hint=${this.el.autocompleteHint}
+      @input=${(e: InputEvent) => {
+        const value = (e.target as GrTextarea).value;
+        this.el.text = value ?? '';
+      }}
+    ></gr-textarea>`;
+  }
+
+  getGrTextarea(): GrTextarea | undefined {
+    return this.el.textarea as GrTextarea | undefined;
+  }
+
+  isFocused() {
+    return !!this.getGrTextarea()?.isFocused;
+  }
+
+  focus() {
+    this.getGrTextarea()?.focus();
+  }
+
+  putCursorAtEnd() {
+    this.getGrTextarea()?.putCursorAtEnd();
+  }
+
+  getCursorPosition(): number {
+    return this.getGrTextarea()?.getCursorPosition() ?? -1;
+  }
+
+  setCursorPosition(pos: number) {
+    this.getGrTextarea()?.setCursorPosition(pos);
+  }
+}
+
 export interface EmojiSuggestion extends Item {
   match: string;
 }
@@ -72,12 +190,14 @@
   }
 }
 
-@customElement('gr-textarea')
-export class GrTextarea extends LitElement {
+@customElement('gr-suggestion-textarea')
+export class GrSuggestionTextarea extends LitElement {
   /**
    * @event bind-value-changed
    */
-  @query('#textarea') textarea?: IronAutogrowTextareaElement;
+  @query('#textarea') textarea?:
+    | (IronAutogrowTextareaElement & LitElement)
+    | GrTextarea;
 
   @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
 
@@ -108,11 +228,19 @@
     standard monospace font. */
   @property({type: Boolean}) code = false;
 
+  /**
+   * An autocompletion hint that is passed to <gr-textarea>, which will allow\
+   * the user to accept it by pressing tab.
+   */
+  @property({type: String}) autocompleteHint = '';
+
   @state() suggestions: (Item | EmojiSuggestion)[] = [];
 
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
+  private readonly flagService = getAppContext().flagsService;
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
@@ -131,6 +259,8 @@
   // private but used in tests
   currentSearchString?: string;
 
+  wrapper: TextAreaWrapper = new IronWrapper(this);
+
   private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
@@ -170,6 +300,10 @@
 
   override connectedCallback() {
     super.connectedCallback();
+
+    const enabled = this.flagService.isEnabled(KnownExperimentId.GR_TEXTAREA);
+    this.wrapper = enabled ? new GrWrapper(this) : new IronWrapper(this);
+
     if (this.monospace) {
       this.classList.add('monospace');
     }
@@ -206,14 +340,27 @@
         #textarea {
           background-color: var(--view-background-color);
           width: 100%;
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: 0;
+          box-sizing: border-box;
+          position: relative;
+          --gr-textarea-padding: var(--spacing-s);
+          --gr-textarea-border-width: 0px;
+          --gr-textarea-border-color: var(--border-color);
+          --input-field-bg: var(--view-background-color);
+          --input-field-disabled-bg: var(--view-background-color);
+          --secondary-bg-color: var(--background-color-secondary);
+          --text-default: var(--primary-text-color);
+          --text-disabled: var(--deemphasized-text-color);
+          --text-secondary: var(--deemphasized-text-color);
+          --iron-autogrow-textarea_-_padding: var(--spacing-s);
         }
         #hiddenText #emojiSuggestions {
           visibility: visible;
           white-space: normal;
         }
-        iron-autogrow-textarea {
-          position: relative;
-        }
         #textarea.noBorder {
           border: none;
         }
@@ -237,19 +384,7 @@
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
       <span id="caratSpan"></span>
       ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
-      <iron-autogrow-textarea
-        id="textarea"
-        class=${classMap({noBorder: this.hideBorder})}
-        .autocomplete=${this.autocomplete}
-        .placeholder=${this.placeholder}
-        ?disabled=${this.disabled}
-        .rows=${this.rows}
-        .maxRows=${this.maxRows}
-        .value=${this.text}
-        @value-changed=${(e: ValueChangedEvent) => {
-          this.text = e.detail.value;
-        }}
-      ></iron-autogrow-textarea>
+      ${this.wrapper.render()}
     `;
   }
 
@@ -282,9 +417,6 @@
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('text')) {
       this.fireChangedEvents();
-      // Add to updated because we want this.textarea.selectionStart and
-      // this.textarea is null in the willUpdate lifecycle
-      this.computeIndexAndSearchString();
       this.handleTextChanged();
     }
   }
@@ -295,22 +427,14 @@
     this.emojiSuggestions?.close();
   }
 
-  getNativeTextarea() {
-    return this.textarea!.textarea;
-  }
-
+  // Note that this may not work as intended, because the textarea is not
+  // rendered yet.
   override focus() {
-    // Note that this may not work as intended, because the textarea is not
-    // rendered yet.
-    this.textarea?.textarea.focus();
+    this.wrapper.focus();
   }
 
   putCursorAtEnd() {
-    const textarea = this.getNativeTextarea();
-    // Put the cursor at the end always.
-    textarea.selectionStart = textarea.value.length;
-    textarea.selectionEnd = textarea.selectionStart;
-    textarea.focus();
+    this.wrapper.putCursorAtEnd();
   }
 
   private getVisibleDropdown() {
@@ -433,8 +557,7 @@
     // below needs to happen after iron-autogrow-textarea has set the
     // incorrect value.
     await this.updateComplete;
-    this.textarea!.selectionStart = specialCharIndex + text.length + move;
-    this.textarea!.selectionEnd = specialCharIndex + text.length + move;
+    this.wrapper.setCursorPosition(specialCharIndex + text.length + move);
     this.resetDropdown();
   }
 
@@ -456,12 +579,11 @@
    * private but used in test
    */
   updateCaratPosition() {
-    if (typeof this.textarea!.value === 'string') {
-      this.hiddenText!.textContent = this.textarea!.value.substring(
-        0,
-        this.textarea!.selectionStart
-      );
+    let position = this.wrapper.getCursorPosition();
+    if (position === -1) {
+      position = this.text.length;
     }
+    this.hiddenText!.textContent = this.text.substring(0, position);
 
     const caratSpan = this.caratSpan!;
     this.hiddenText!.appendChild(caratSpan);
@@ -474,9 +596,9 @@
     // - The search string is an space or new line
     // - The colon has been removed
     // - There are no suggestions that match the search string
+    const position = this.wrapper.getCursorPosition();
     return (
-      this.textarea!.selectionStart !==
-        (this.currentSearchString ?? '').length + charIndex + 1 ||
+      position !== (this.currentSearchString ?? '').length + charIndex + 1 ||
       this.currentSearchString === ' ' ||
       this.currentSearchString === '\n' ||
       !(text[charIndex] === char)
@@ -522,7 +644,7 @@
       )
     ) {
       this.resetDropdown();
-    } else if (activeDropdown!.isHidden && this.textarea!.focused) {
+    } else if (activeDropdown!.isHidden && this.wrapper.isFocused()) {
       // Otherwise open the dropdown and set the position to be just below the
       // cursor.
       // Do not open dropdown if textarea is not focused
@@ -543,8 +665,11 @@
     );
   }
 
-  private computeIndexAndSearchString() {
-    const currentCarat = this.textarea?.selectionStart ?? this.text.length;
+  public computeIndexAndSearchString() {
+    let currentCarat = this.wrapper.getCursorPosition();
+    if (currentCarat === -1) {
+      currentCarat = this.text.length;
+    }
     const m = this.text
       .substring(0, currentCarat)
       .match(/(?:^|\s)([:@][\S]*)$/);
@@ -561,6 +686,7 @@
 
   // Private but used in tests.
   async handleTextChanged() {
+    this.computeIndexAndSearchString();
     await this.computeSuggestions();
     this.openOrResetDropdown();
     this.focus();
@@ -643,10 +769,8 @@
     // When nothing is selected, selectionStart is the caret position. We want
     // the indentation level of the current line, not the end of the text which
     // may be different.
-    const currentLine = this.textarea!.textarea.value.substring(
-      0,
-      this.textarea!.selectionStart
-    )
+    const currentLine = this.text
+      .substring(0, this.wrapper.getCursorPosition())
       .split('\n')
       .pop();
     const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
@@ -669,6 +793,6 @@
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-textarea': GrTextarea;
+    'gr-suggestion-textarea': GrSuggestionTextarea;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
index d84f5a7..7ad3429 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
@@ -4,8 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import './gr-textarea';
-import {GrTextarea} from './gr-textarea';
+import './gr-suggestion-textarea';
+import {GrSuggestionTextarea} from './gr-suggestion-textarea';
 import {
   Item,
   ItemSelectedEventDetail,
@@ -13,23 +13,59 @@
 import {
   mockPromise,
   pressKey,
+  stubFlags,
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
-suite('gr-textarea tests', () => {
-  let element: GrTextarea;
+suite('gr-suggestion-textarea tests with <gr-textarea>', () =>
+  createSuite(true)
+);
+
+suite('gr-suggestion-textarea tests with <iron-autogrow-textarea>', () =>
+  createSuite(false)
+);
+
+function createSuite(grTextareaEnabled: boolean) {
+  let element: GrSuggestionTextarea;
+
+  const setText = async (text: string) => {
+    element.text = text;
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.wrapper.setCursorPosition(text.length);
+    element.handleTextChanged();
+    await element.updateComplete;
+  };
 
   setup(async () => {
-    element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
+    stubFlags('isEnabled')
+      .withArgs(KnownExperimentId.GR_TEXTAREA)
+      .returns(grTextareaEnabled);
+    element = await fixture<GrSuggestionTextarea>(
+      html`<gr-suggestion-textarea></gr-suggestion-textarea>`
+    );
     sinon.stub(element.reporting, 'reportInteraction');
     await element.updateComplete;
   });
 
   test('renders', () => {
+    const textareaHtml = grTextareaEnabled
+      ? /* HTML */ `
+          <gr-textarea putcursoratendonfocus id="textarea"> </gr-textarea>
+        `
+      : /* HTML */ `
+          <iron-autogrow-textarea
+            aria-disabled="false"
+            focused=""
+            id="textarea"
+          >
+          </iron-autogrow-textarea>
+        `;
     assert.shadowDom.equal(
       element,
       /* HTML */ `<div id="hiddenText"></div>
@@ -42,8 +78,7 @@
           role="listbox"
         >
         </gr-autocomplete-dropdown>
-        <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea>`,
+        ${textareaHtml}`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -66,17 +101,14 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
-
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await waitUntil(() => element.wrapper.isFocused() === true);
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
       assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
-      assert.isTrue(element.textarea!.focused);
+      assert.isTrue(element.wrapper.isFocused());
 
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
@@ -85,8 +117,7 @@
       assert.isFalse(element.mentionsSuggestions!.isHidden);
       assert.equal(element.currentSearchString, '');
 
-      element.text = '@abc@google.com';
-      await element.updateComplete;
+      await setText('@abc@google.com');
 
       assert.equal(element.currentSearchString, 'abc@google.com');
       assert.equal(element.specialCharIndex, 0);
@@ -104,11 +135,9 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '\n@';
+      await setText('\n@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -132,14 +161,12 @@
       const promise = mockPromise<Item[]>();
       stubRestApi('queryAccounts').returns(promise);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
       element.suggestions = [
         {dataValue: 'prior@google.com', text: 'Prior suggestion'},
       ];
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await element.updateComplete;
       assert.equal(element.suggestions.length, 0);
@@ -167,16 +194,13 @@
       const suggestionStub = stubRestApi('queryAccounts');
       suggestionStub.returns(promise1);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
-      await element.updateComplete;
+      await setText('@');
       assert.equal(element.currentSearchString, '');
 
       suggestionStub.returns(promise2);
-      element.text = '@abc@google.com';
+      await setText('@abc@google.com');
       // None of suggestions returned yet.
       assert.equal(element.suggestions.length, 0);
       await element.updateComplete;
@@ -229,11 +253,9 @@
       );
 
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -253,7 +275,6 @@
     test('emoji dropdown does not open if mention dropdown is open', async () => {
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
-      const resetSpy = sinon.spy(element, 'resetDropdown');
       stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
@@ -261,11 +282,9 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
       element.suggestions = [
         {
           name: 'a',
@@ -275,30 +294,28 @@
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
-      assert.isFalse(resetSpy.called);
-
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h';
+      await setText('@h');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h';
+      await setText('@h');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h:';
+      await setText('@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 setText('@h:D');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -309,11 +326,9 @@
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
+      await setText(':');
       element.suggestions = [
         {
           name: 'a',
@@ -325,23 +340,23 @@
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D';
+      await setText(':D');
       await element.updateComplete;
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@';
+      await setText(':D@');
       await element.updateComplete;
       // emoji dropdown hidden since we have no more suggestions
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@b';
+      await setText(':D@b');
       await element.updateComplete;
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@b ';
+      await setText(':D@b ');
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
@@ -356,11 +371,9 @@
       );
 
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -385,8 +398,7 @@
     // by default textarea has focus when rendered
     // explicitly remove focus from the element for the test
     element.blur();
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
+    element.wrapper.setCursorPosition(1);
     element.text = ':';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -394,9 +406,8 @@
 
   test('emoji selector is not open when a general text is entered', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    element.textarea!.selectionStart = 9;
-    element.textarea!.selectionEnd = 9;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    element.wrapper.setCursorPosition(9);
     element.text = 'some text';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -408,13 +419,13 @@
     const listenerStub = sinon.stub();
     element.addEventListener('text-changed', listenerStub);
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
-    element.text = ':';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText(':');
     assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
-    assert.isTrue(element.textarea!.focused);
+    assert.isTrue(element.wrapper.isFocused());
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    await element.emojiSuggestions!.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 0);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -423,13 +434,8 @@
 
   test('emoji selector opens when a colon is typed after space', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 2;
-    element.textarea!.selectionEnd = 2;
-    element.text = ' :';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText(' :');
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 1);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -438,30 +444,17 @@
 
   test('emoji selector doesn`t open when a colon is typed after character', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 5;
-    element.textarea!.selectionEnd = 5;
-    element.text = 'test:';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText('test:');
     assert.isTrue(element.emojiSuggestions!.isHidden);
     assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
   test('emoji selector opens when a colon is typed and some substring', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
-    element.text = ':';
-    await element.updateComplete;
-    element.textarea!.selectionStart = 2;
-    element.textarea!.selectionEnd = 2;
-    element.text = ':t';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText(':');
+    await setText(':t');
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 0);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -472,19 +465,11 @@
     element.textarea!.focus();
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
+    element.wrapper.setCursorPosition(1);
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 1
     const text = ': hello';
-    sinon.stub(element, 'textarea').value({
-      selectionStart: 1,
-      value: text,
-      focused: true,
-      textarea: {
-        focus: () => {},
-      },
-    });
+    sinon.stub(element.wrapper, 'getCursorPosition').returns(1);
     element.text = text;
     await element.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -495,25 +480,14 @@
 
   test('emoji selector closes when text changes before the colon', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    await element.updateComplete;
-    element.textarea!.selectionStart = 10;
-    element.textarea!.selectionEnd = 10;
-    element.text = 'test test ';
-    await element.updateComplete;
-    element.textarea!.selectionStart = 12;
-    element.textarea!.selectionEnd = 12;
-
-    element.text = 'test test :';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText('test test ');
+    await setText('test test :');
 
     // typing : opens the selector
     assert.isFalse(element.emojiSuggestions!.isHidden);
 
-    element.textarea!.selectionStart = 15;
-    element.textarea!.selectionEnd = 15;
-    element.text = 'test test :smi';
-    await element.updateComplete;
+    await setText('test test :smi');
 
     assert.equal(element.currentSearchString, 'smi');
     assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -571,10 +545,12 @@
   });
 
   test('handleDropdownItemSelect', async () => {
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.wrapper.setCursorPosition(16);
     element.specialCharIndex = 10;
+    element.handleTextChanged();
     await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
     const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
@@ -585,46 +561,37 @@
 
     // wait for reset dropdown to finish
     await waitUntil(() => element.specialCharIndex === -1);
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
-    element.specialCharIndex = 10;
     await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.wrapper.setCursorPosition(16);
+    await element.updateComplete;
+    element.specialCharIndex = 10;
+    element.handleTextChanged();
     // move the cursor to the left while the suggestion popup is open
-    element.textarea!.selectionStart = 0;
+    element.wrapper.setCursorPosition(0);
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂');
 
     // wait for reset dropdown to finish
     await waitUntil(() => element.specialCharIndex === -1);
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
+    element.wrapper.setCursorPosition(16);
     const text = 'test test :tears happy';
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 16
-    const stub = sinon.stub(element, 'textarea').value({
-      selectionStart: 16,
-      value: text,
-      focused: true,
-      textarea: {
-        focus: () => {},
-      },
-    });
+    const stub = sinon.stub(element.wrapper, 'getCursorPosition').returns(16);
     element.text = text;
     element.specialCharIndex = 10;
     await element.updateComplete;
     stub.restore();
     // move the cursor to the right while the suggestion popup is open
-    element.textarea!.selectionStart = 22;
+    element.wrapper.setCursorPosition(22);
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂 happy');
   });
 
   test('updateCaratPosition', async () => {
-    element.textarea!.selectionStart = 4;
-    element.textarea!.selectionEnd = 4;
-    element.text = 'test';
-    await element.updateComplete;
+    await setText('test');
     element.updateCaratPosition();
     assert.deepEqual(
       element.hiddenText!.innerHTML,
@@ -634,7 +601,7 @@
 
   test('newline receives matching indentation', async () => {
     const indentCommand = sinon.stub(document, 'execCommand');
-    element.textarea!.value = '    a';
+    await setText('    a');
     element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
     await element.updateComplete;
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
@@ -653,24 +620,11 @@
   });
 
   suite('keyboard shortcuts', async () => {
-    async function setupDropdown() {
-      element.textarea!.focus();
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
-      await element.updateComplete;
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 2;
-      element.text = ':1';
-      await element.emojiSuggestions!.updateComplete;
-      await element.updateComplete;
-    }
-
     test('escape key', async () => {
       const resetSpy = sinon.spy(element, 'resetDropdown');
       pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isFalse(resetSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isTrue(resetSpy.called);
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -680,7 +634,7 @@
       const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
       pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isFalse(upSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isTrue(upSpy.called);
     });
@@ -689,7 +643,7 @@
       const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
       pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isFalse(downSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isTrue(downSpy.called);
     });
@@ -698,7 +652,7 @@
       const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
       pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isTrue(enterSpy.called);
       await element.updateComplete;
@@ -706,12 +660,12 @@
     });
   });
 
-  suite('gr-textarea monospace', () => {
-    let element: GrTextarea;
+  suite('gr-suggestion-textarea monospace', () => {
+    let element: GrSuggestionTextarea;
 
     setup(async () => {
-      element = await fixture<GrTextarea>(
-        html`<gr-textarea monospace></gr-textarea>`
+      element = await fixture<GrSuggestionTextarea>(
+        html`<gr-suggestion-textarea monospace></gr-suggestion-textarea>`
       );
       await element.updateComplete;
     });
@@ -721,12 +675,12 @@
     });
   });
 
-  suite('gr-textarea hideBorder', () => {
-    let element: GrTextarea;
+  suite('gr-suggestion-textarea hideBorder', () => {
+    let element: GrSuggestionTextarea;
 
     setup(async () => {
-      element = await fixture<GrTextarea>(
-        html`<gr-textarea hide-border></gr-textarea>`
+      element = await fixture<GrSuggestionTextarea>(
+        html`<gr-suggestion-textarea hide-border></gr-suggestion-textarea>`
       );
       await element.updateComplete;
     });
@@ -735,4 +689,4 @@
       assert.isTrue(element.textarea!.classList.contains('noBorder'));
     });
   });
-});
+}
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 5074290..9337951 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
@@ -23,6 +23,7 @@
   ['application/typescript', 'typescript'],
   ['application/xml', 'xml'],
   ['application/xquery', 'xquery'],
+  ['application/x-epp', 'epp'],
   ['application/x-erb', 'erb'],
   ['text/css', 'css'],
   ['text/html', 'html'],
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
 import '../api/embed';
 import '../scripts/bundled-polymer';
 import './diff/gr-diff/gr-diff';
+import './gr-textarea';
 import './diff/gr-diff-cursor/gr-diff-cursor';
 import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
 import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..05bef49
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,765 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  GrTextarea as GrTextareaApi,
+  HintAppliedEventDetail,
+  HintShownEventDetail,
+  HintDismissedEventDetail,
+  CursorPositionChangeEventDetail,
+} from '../api/embed';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+  return new Promise(resolve => {
+    requestAnimationFrame(() => {
+      resolve();
+    });
+  });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+  const div = document.createElement('div');
+  try {
+    div.contentEditable = 'PLAINTEXT-ONLY';
+    return div.contentEditable === 'plaintext-only';
+  } catch (e) {
+    return false;
+  }
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+  'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement implements GrTextareaApi {
+  // editableDivElement is available right away where it may be undefined. This
+  // is used for calls for scrollTop as if it is undefined then we can fallback
+  // to 0. For other usecases use editableDiv.
+  @query('.editableDiv')
+  private readonly editableDivElement?: HTMLDivElement;
+
+  @queryAsync('.editableDiv')
+  private readonly editableDiv?: Promise<HTMLDivElement>;
+
+  @property({type: Boolean, reflect: true}) disabled = false;
+
+  @property({type: String, reflect: true}) placeholder: string | undefined;
+
+  /**
+   * The hint is shown as a autocomplete string which can be added by pressing
+   * TAB.
+   *
+   * The hint is shown
+   *  1. At the cursor position, only when cursor position is at the end of
+   *     textarea content.
+   *  2. When textarea has focus.
+   *  3. When selection inside the textarea is collapsed.
+   *
+   * When hint is applied listen for hintApplied event and remove the hint
+   * as component property to avoid showing the hint again.
+   */
+  @property({type: String})
+  set hint(newHint) {
+    if (this.hint !== newHint) {
+      this.innerHint = newHint;
+      this.updateHintInDomIfRendered();
+    }
+  }
+
+  get hint() {
+    return this.innerHint;
+  }
+
+  /**
+   * Show hint is shown as placeholder which people can autocomplete to.
+   *
+   * This takes precedence over hint property.
+   * It is shown even when textarea has no focus.
+   * This is shown only when textarea is blank.
+   */
+  @property({type: String}) placeholderHint: string | undefined;
+
+  /**
+   * Sets the value for textarea and also renders it in dom if it is different
+   * from last rendered value.
+   *
+   * To prevent cursor position from jumping to front of text even when value
+   * remains same, Check existing value before triggering the update and only
+   * update when there is a change.
+   *
+   * Also .innerText binding can't be used for security reasons.
+   */
+  @property({type: String})
+  set value(newValue) {
+    if (this.ignoreValue && this.ignoreValue === newValue) {
+      return;
+    }
+    const oldVal = this.value;
+    if (oldVal !== newValue) {
+      this.innerValue = newValue;
+      this.updateValueInDom();
+    }
+  }
+
+  get value() {
+    return this.innerValue;
+  }
+
+  /**
+   * This value will be ignored by textarea and is not set.
+   */
+  @property({type: String}) ignoreValue: string | undefined;
+
+  /**
+   * Sets cursor at the end of content on focus.
+   */
+  @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+  /**
+   * Enables save shortcut.
+   *
+   * On S key down with control or meta key enabled is exposed with output event
+   * 'saveShortcut'.
+   */
+  @property({type: Boolean}) enableSaveShortcut = false;
+
+  /*
+   * Is textarea focused. This is a readonly property.
+   */
+  get isFocused(): boolean {
+    return this.focused;
+  }
+
+  /**
+   * Native element for editable div.
+   */
+  get nativeElement() {
+    return this.editableDivElement;
+  }
+
+  /**
+   * Scroll Top for editable div.
+   */
+  override get scrollTop() {
+    return this.editableDivElement?.scrollTop ?? 0;
+  }
+
+  private innerValue: string | undefined;
+
+  private innerHint: string | undefined;
+
+  private focused = false;
+
+  private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          position: relative;
+          width: 100%;
+        }
+
+        :host([disabled]) {
+          .editableDiv {
+            background-color: var(--input-field-disabled-bg, lightgrey);
+            color: var(--text-disabled, black);
+            cursor: default;
+          }
+        }
+
+        .editableDiv {
+          background-color: var(--input-field-bg, white);
+          border: var(--gr-textarea-border-width, 2px) solid
+            var(--gr-textarea-border-color, white);
+          border-radius: 4px;
+          box-sizing: border-box;
+          color: var(--text-default, black);
+          max-height: var(--gr-textarea-max-height, 16em);
+          min-height: var(--gr-textarea-min-height, 4em);
+          overflow-x: auto;
+          padding: var(--gr-textarea-padding, 12px);
+          white-space: pre-wrap;
+          width: 100%;
+
+          &:focus-visible {
+            border-color: var(--gr-textarea-focus-outline-color, black);
+            outline: none;
+          }
+
+          &:empty::before {
+            content: attr(data-placeholder);
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+          }
+
+          &.hintShown:empty::after,
+          .autocomplete-hint:empty::after {
+            background-color: var(--secondary-bg-color, white);
+            border: 1px solid var(--text-secondary, lightgrey);
+            border-radius: 2px;
+            content: 'tab';
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+            font-size: 10px;
+            line-height: 10px;
+            margin-left: 4px;
+            padding: 1px 3px;
+          }
+
+          .autocomplete-hint {
+            &:empty::before {
+              content: attr(data-hint);
+              color: var(--text-secondary, lightgrey);
+            }
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const isHintShownAsPlaceholder =
+      (!this.disabled && this.placeholderHint) ?? false;
+
+    const placeholder = isHintShownAsPlaceholder
+      ? this.placeholderHint
+      : this.placeholder;
+    const ariaPlaceholder = isHintShownAsPlaceholder
+      ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+      : placeholder;
+
+    const classes = classMap({
+      editableDiv: true,
+      hintShown: isHintShownAsPlaceholder,
+    });
+
+    // Chrome supports non-standard "contenteditable=plaintext-only",
+    // which prevents HTML from being inserted into a contenteditable element.
+    // https://github.com/w3c/editing/issues/162
+    return html`<div
+      aria-disabled=${this.disabled}
+      aria-multiline="true"
+      aria-placeholder=${ifDefined(ariaPlaceholder)}
+      data-placeholder=${ifDefined(placeholder)}
+      class=${classes}
+      contenteditable=${this.contentEditableAttributeValue}
+      dir="ltr"
+      role="textbox"
+      @input=${this.onInput}
+      @focus=${this.onFocus}
+      @blur=${this.onBlur}
+      @keydown=${this.handleKeyDown}
+      @keyup=${this.handleKeyUp}
+      @mouseup=${this.handleMouseUp}
+      @scroll=${this.handleScroll}
+    ></div>`;
+  }
+
+  /**
+   * Focuses the textarea.
+   */
+  override async focus() {
+    const editableDivElement = await this.editableDiv;
+    const isFocused = this.isFocused;
+    editableDivElement?.focus?.();
+    // If already focused, do not change the cursor position.
+    if (this.putCursorAtEndOnFocus && !isFocused) {
+      await this.putCursorAtEnd();
+    }
+  }
+
+  /**
+   * Puts the cursor at the end of existing content.
+   * Scrolls the content of textarea towards the end.
+   */
+  async putCursorAtEnd() {
+    const editableDivElement = await this.editableDiv;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+
+    const range = document.createRange();
+    editableDivElement.focus();
+    range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    this.onCursorPositionChange();
+  }
+
+  public setCursorPosition(position: number) {
+    this.setCursorPositionForDiv(position, this.editableDivElement);
+  }
+
+  public async setCursorPositionAsync(position: number) {
+    const editableDivElement = await this.editableDiv;
+    this.setCursorPositionForDiv(position, editableDivElement);
+  }
+
+  /**
+   * Sets cursor position to given position and scrolls the content to cursor
+   * position.
+   *
+   * If position is out of bounds of value of textarea then cursor is places at
+   * end of content of textarea.
+   */
+  private setCursorPositionForDiv(
+    position: number,
+    editableDivElement?: HTMLDivElement
+  ) {
+    // This will keep track of remaining offset to place the cursor.
+    let remainingOffset = position;
+    let isOnFreshLine = true;
+    let nodeToFocusOn: Node | null = null;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+    editableDivElement.focus();
+    const findNodeToFocusOn = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+        let currentNodeLength = 0;
+
+        if (childNode.nodeType === Node.COMMENT_NODE) {
+          continue;
+        }
+
+        if (childNode.nodeName === 'BR') {
+          currentNodeLength++;
+          isOnFreshLine = true;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          currentNodeLength++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+          currentNodeLength += childNode.textContent.length;
+        }
+
+        if (remainingOffset <= currentNodeLength) {
+          nodeToFocusOn = childNode;
+          break;
+        } else {
+          remainingOffset -= currentNodeLength;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findNodeToFocusOn(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+    this.setFocusOnNode(
+      selection,
+      editableDivElement,
+      nodeToFocusOn,
+      remainingOffset
+    );
+  }
+
+  /**
+   * Replaces text from start and end cursor position.
+   */
+  setRangeText(replacement: string, start: number, end: number) {
+    const pre = this.value?.substring(0, start) ?? '';
+    const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+    this.value = pre + replacement + post;
+    this.setCursorPosition(pre.length + replacement.length);
+  }
+
+  private get contentEditableAttributeValue() {
+    return this.disabled
+      ? 'false'
+      : this.isPlaintextOnlySupported
+      ? ('plaintext-only' as unknown as 'true')
+      : 'true';
+  }
+
+  private setFocusOnNode(
+    selection: Selection,
+    editableDivElement: Node,
+    nodeToFocusOn: Node | null,
+    remainingOffset: number
+  ) {
+    const range = document.createRange();
+    // If node is null or undefined then fallback to focus event which will put
+    // cursor at the end of content.
+    if (nodeToFocusOn === null) {
+      range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    }
+    // If node to focus is BR then focus offset is number of nodes.
+    else if (nodeToFocusOn.nodeName === 'BR') {
+      const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+      range.setEnd(nextNode, 0);
+    } else {
+      range.setStart(nodeToFocusOn, remainingOffset);
+    }
+
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    // Scroll the content to cursor position.
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    this.onCursorPositionChange();
+  }
+
+  private async onInput(event: Event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+
+    const value = await this.getValue();
+    this.innerValue = value;
+
+    this.fire('input', {value: this.value});
+  }
+
+  private onFocus() {
+    this.focused = true;
+    this.onCursorPositionChange();
+  }
+
+  private onBlur() {
+    this.focused = false;
+    this.removeHintSpanIfShown();
+    this.onCursorPositionChange();
+  }
+
+  private async handleKeyDown(event: KeyboardEvent) {
+    if (
+      event.key === 'Tab' &&
+      !event.shiftKey &&
+      !event.ctrlKey &&
+      !event.metaKey
+    ) {
+      await this.handleTabKeyPress(event);
+      return;
+    }
+    if (
+      this.enableSaveShortcut &&
+      event.key === 's' &&
+      (event.ctrlKey || event.metaKey)
+    ) {
+      event.preventDefault();
+      this.fire('saveShortcut');
+    }
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private handleKeyUp() {
+    this.onCursorPositionChange();
+  }
+
+  private async handleMouseUp() {
+    this.onCursorPositionChange();
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private handleScroll() {
+    this.fire('scroll');
+  }
+
+  private fire<T>(type: string, detail?: T) {
+    this.dispatchEvent(
+      new CustomEvent(type, {detail, bubbles: true, composed: true})
+    );
+  }
+
+  private async handleTabKeyPress(event: KeyboardEvent) {
+    const oldValue = this.value;
+    if (this.placeholderHint && !oldValue) {
+      event.preventDefault();
+      await this.appendHint(this.placeholderHint, event);
+    } else if (this.hasHintSpan()) {
+      event.preventDefault();
+      await this.appendHint(this.hint!, event);
+    }
+  }
+
+  private async appendHint(hint: string, event: Event) {
+    const oldValue = this.value ?? '';
+    const newValue = oldValue + hint;
+
+    this.value = newValue;
+    await this.putCursorAtEnd();
+    await this.onInput(event);
+
+    this.fire('hintApplied', {hint, oldValue});
+  }
+
+  private async toggleHintVisibilityIfAny() {
+    // Wait for the next animation frame so that entered key is processed and
+    // available in dom.
+    await animationFrame();
+
+    const editableDivElement = await this.editableDiv;
+    const currentValue = (await this.getValue()) ?? '';
+    const cursorPosition = await this.getCursorPositionAsync();
+    if (
+      !editableDivElement ||
+      (this.placeholderHint && !currentValue) ||
+      !this.hint ||
+      !this.isFocused ||
+      cursorPosition !== currentValue.length
+    ) {
+      this.removeHintSpanIfShown();
+      return;
+    }
+
+    const hintSpan = this.hintSpan();
+    if (!hintSpan) {
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+      return;
+    }
+
+    const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+    if (oldHint !== this.hint) {
+      this.removeHintSpanIfShown();
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+    }
+  }
+
+  private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+    const oldValue = this.value ?? '';
+    const hintSpan = document.createElement('span');
+    hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+    hintSpan.setAttribute('role', 'alert');
+    hintSpan.setAttribute(
+      'aria-label',
+      'Suggestion: ' + hint + ' Press TAB to accept it.'
+    );
+    hintSpan.dataset['hint'] = hint;
+    editableDivElement.appendChild(hintSpan);
+    this.fire('hintShown', {hint, oldValue});
+  }
+
+  private removeHintSpanIfShown() {
+    const hintSpan = this.hintSpan();
+    if (hintSpan) {
+      hintSpan.remove();
+      this.fire('hintDismissed', {
+        hint: (hintSpan as HTMLElement).dataset['hint'],
+      });
+    }
+  }
+
+  private hasHintSpan() {
+    return !!this.hintSpan();
+  }
+
+  private hintSpan() {
+    return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+  }
+
+  private onCursorPositionChange() {
+    this.fire('cursorPositionChange', {position: this.getCursorPosition()});
+  }
+
+  private async updateValueInDom() {
+    const editableDivElement =
+      this.editableDivElement ?? (await this.editableDiv);
+    if (editableDivElement) {
+      editableDivElement.innerText = this.value || '';
+    }
+  }
+
+  private async updateHintInDomIfRendered() {
+    // Wait for editable div to render then process the hint.
+    await this.editableDiv;
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private async getValue() {
+    const editableDivElement = await this.editableDiv;
+    if (editableDivElement) {
+      const [output] = this.parseText(editableDivElement, false, true);
+      return output;
+    }
+    return '';
+  }
+
+  private parseText(
+    node: Node,
+    isLastBr: boolean,
+    isFirst: boolean
+  ): [string, boolean] {
+    let textValue = '';
+    let output = '';
+    if (node.nodeName === 'BR') {
+      return ['\n', true];
+    }
+
+    if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+      return [node.textContent, false];
+    }
+
+    if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+      textValue = '\n';
+    }
+
+    isLastBr = false;
+
+    for (let i = 0; i < node.childNodes?.length; i++) {
+      [output, isLastBr] = this.parseText(
+        node.childNodes[i],
+        isLastBr,
+        i === 0
+      );
+      textValue += output;
+    }
+    return [textValue, isLastBr];
+  }
+
+  public getCursorPosition() {
+    return this.getCursorPositionForDiv(this.editableDivElement);
+  }
+
+  public async getCursorPositionAsync() {
+    const editableDivElement = await this.editableDiv;
+    return this.getCursorPositionForDiv(editableDivElement);
+  }
+
+  private getCursorPositionForDiv(editableDivElement?: HTMLDivElement) {
+    const selection = this.getSelection();
+
+    // Cursor position is -1 (not available) if
+    //
+    // If textarea is not rendered.
+    // If textarea is not focused
+    // There is no accessible selection object.
+    // This is not a collapsed selection.
+    if (
+      !editableDivElement ||
+      !this.focused ||
+      !selection ||
+      selection.focusNode === null ||
+      !selection.isCollapsed
+    ) {
+      return -1;
+    }
+
+    let cursorPosition = 0;
+    let isOnFreshLine = true;
+
+    const findCursorPosition = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+
+        if (childNode.nodeName === 'BR') {
+          cursorPosition++;
+          isOnFreshLine = true;
+          continue;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          cursorPosition++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode === selection.focusNode) {
+          cursorPosition += selection.focusOffset;
+          break;
+        } else if (childNode.nodeType === 3 && childNode.textContent) {
+          cursorPosition += childNode.textContent.length;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findCursorPosition(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    if (editableDivElement === selection.focusNode) {
+      // If focus node is the top textarea then focusOffset is the number of
+      // child nodes before the cursor position.
+      const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+        0,
+        selection.focusOffset
+      );
+      findCursorPosition(partOfNodes);
+    } else {
+      findCursorPosition(Array.from(editableDivElement.childNodes));
+    }
+
+    return cursorPosition;
+  }
+
+  /** Gets the current selection, preferring the shadow DOM selection. */
+  private getSelection(): Selection | undefined | null {
+    // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+    return this.shadowRoot?.getSelection?.();
+  }
+
+  private scrollToCursorPosition(range: Range) {
+    const tempAnchorEl = document.createElement('br');
+    range.insertNode(tempAnchorEl);
+
+    tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+    tempAnchorEl.remove();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-textarea': GrTextarea;
+  }
+  interface HTMLElementEventMap {
+    // prettier-ignore
+    'saveShortcut': CustomEvent<{}>;
+    // prettier-ignore
+    'hintApplied': CustomEvent<HintAppliedEventDetail>;
+    // prettier-ignore
+    'hintShown': CustomEvent<HintShownEventDetail>;
+    // prettier-ignore
+    'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+    // prettier-ignore
+    'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..b701dcb
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,235 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {AUTOCOMPLETE_HINT_CLASS, GrTextarea} from './gr-textarea';
+import {CursorPositionChangeEventDetail} from '../api/embed';
+
+async function rafPromise() {
+  return new Promise(res => {
+    requestAnimationFrame(res);
+  });
+}
+
+suite('gr-textarea test', () => {
+  let element: GrTextarea;
+
+  setup(async () => {
+    element = await fixture(html` <gr-textarea> </gr-textarea>`);
+  });
+
+  test('text area is registered correctly', () => {
+    assert.instanceOf(element, GrTextarea);
+  });
+
+  test('when disabled textarea have contenteditable set to false', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+  });
+
+  test('when disabled textarea have aria-disabled set', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+  });
+
+  test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+    const placeholder = 'A sample placehodler...';
+    element.placeholder = placeholder;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+  });
+
+  test('renders the value', async () => {
+    const value = 'Some value';
+    element.value = value;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.innerText, value);
+  });
+
+  test('streams change event when editable div has input event', async () => {
+    const value = 'Some value \n other value';
+    const INPUT_EVENT = 'input';
+    let changeCalled = false;
+
+    element.addEventListener(INPUT_EVENT, () => {
+      changeCalled = true;
+    });
+
+    const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+
+    editableDiv.innerText = value;
+    editableDiv.dispatchEvent(new Event('input'));
+    await changeEventPromise;
+
+    assert.isTrue(changeCalled);
+  });
+
+  test('does not have focus by default', async () => {
+    assert.isFalse(element.isFocused);
+  });
+
+  test('when focused, isFocused is set to true', async () => {
+    await element.focus();
+    assert.isTrue(element.isFocused);
+  });
+
+  test('when cursor position is set to 0', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.setCursorPosition(0);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 0);
+  });
+
+  test('when cursor position is set to 1', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some value';
+    await element.updateComplete;
+    element.setCursorPosition(1);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 1);
+  });
+
+  test('when cursor position is set to new line', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some \n\n\n value';
+    await element.updateComplete;
+    element.setCursorPosition(7);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 7);
+  });
+
+  test('when textarea is empty, placeholder hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+  });
+
+  test('when TAB is pressed, placeholder hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, placeholderHint);
+  });
+
+  test('when cursor is at end, hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    await element.updateComplete;
+    element.value = oldValue;
+    await element.putCursorAtEnd();
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await element.updateComplete;
+    await rafPromise();
+
+    const spanHintElement = editableDiv?.querySelector(
+      '.' + AUTOCOMPLETE_HINT_CLASS
+    ) as HTMLSpanElement;
+    const styles = window.getComputedStyle(spanHintElement, ':before');
+    assert.equal(styles['content'], '"' + hint + '"');
+  });
+
+  test('when TAB is pressed, hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    element.value = oldValue;
+    await element.updateComplete;
+    await element.putCursorAtEnd();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await rafPromise();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, oldValue + hint);
+  });
+});
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts/accounts-model.ts
similarity index 100%
rename from polygerrit-ui/app/models/accounts-model/accounts-model.ts
rename to polygerrit-ui/app/models/accounts/accounts-model.ts
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
similarity index 100%
rename from polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
rename to polygerrit-ui/app/models/accounts/accounts-model_test.ts
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index bba7e47..373f199 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -739,27 +739,21 @@
    *     has been loaded, and false if a newer patch has been uploaded in the
    *     meantime. The promise is rejected on network error.
    */
-  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
-    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-    return this.restApiService.getChangeDetail(change._number).then(detail => {
-      if (!detail) {
-        const error = new Error('Change detail not found.');
-        return Promise.reject(error);
-      }
-      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-      if (!actualLatest || !knownLatest) {
-        const error = new Error('Unable to check for latest patchset.');
-        return Promise.reject(error);
-      }
-      return {
-        isLatest: actualLatest <= knownLatest,
-        newStatus: change.status !== detail.status ? detail.status : null,
-        newMessages:
-          (change.messages || []).length < (detail.messages || []).length
-            ? detail.messages![detail.messages!.length - 1]
-            : undefined,
-      };
-    });
+  async fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = change.current_revision_number;
+    const detail = await this.restApiService.getChange(change._number);
+    if (!detail) {
+      throw new Error('Change request failed.');
+    }
+    const actualLatest = detail.current_revision_number;
+    return {
+      isLatest: actualLatest <= knownLatest,
+      newStatus: change.status !== detail.status ? detail.status : null,
+      newMessages:
+        (change.messages || []).length < (detail.messages || []).length
+          ? detail.messages![detail.messages!.length - 1]
+          : undefined,
+    };
   }
 
   /**
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index ebe6066..bf7b9dc 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -23,6 +23,7 @@
 } from '../../test/test-utils';
 import {
   BasePatchSetNum,
+  ChangeInfo,
   CommitId,
   EDIT,
   NumericChangeId,
@@ -101,6 +102,7 @@
   let changeViewModel: ChangeViewModel;
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
+  let knownChangeNoRevision: ChangeInfo;
   const testCompleted = new Subject<void>();
 
   async function waitForLoadingStatus(
@@ -123,15 +125,19 @@
       testResolver(pluginLoaderToken),
       getAppContext().reportingService
     );
-    knownChange = {
+    knownChangeNoRevision = {
       ...createChange(),
+      status: ChangeStatus.NEW,
+      current_revision_number: 2 as PatchSetNumber,
+      messages: [],
+    };
+    knownChange = {
+      ...knownChangeNoRevision,
       revisions: {
         sha1: {...createRevision(1), description: 'patch 1'},
         sha2: {...createRevision(2), description: 'patch 2'},
       },
-      status: ChangeStatus.NEW,
       current_revision: 'abc' as CommitId,
-      messages: [],
     };
   });
 
@@ -377,7 +383,7 @@
   });
 
   test('changeModel.fetchChangeUpdates on latest', async () => {
-    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    stubRestApi('getChange').returns(Promise.resolve(knownChangeNoRevision));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.isNotOk(result.newStatus);
@@ -386,17 +392,10 @@
 
   test('changeModel.fetchChangeUpdates not on latest', async () => {
     const actualChange = {
-      ...knownChange,
-      revisions: {
-        ...knownChange.revisions,
-        sha3: {
-          ...createRevision(3),
-          description: 'patch 3',
-          _number: 3 as PatchSetNumber,
-        },
-      },
+      ...knownChangeNoRevision,
+      current_revision_number: 3 as PatchSetNumber,
     };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    stubRestApi('getChange').returns(Promise.resolve(actualChange));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isFalse(result.isLatest);
     assert.isNotOk(result.newStatus);
@@ -405,10 +404,10 @@
 
   test('changeModel.fetchChangeUpdates new status', async () => {
     const actualChange = {
-      ...knownChange,
+      ...knownChangeNoRevision,
       status: ChangeStatus.MERGED,
     };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    stubRestApi('getChange').returns(Promise.resolve(actualChange));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.equal(result.newStatus, ChangeStatus.MERGED);
@@ -417,10 +416,10 @@
 
   test('changeModel.fetchChangeUpdates new messages', async () => {
     const actualChange = {
-      ...knownChange,
+      ...knownChangeNoRevision,
       messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
     };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    stubRestApi('getChange').returns(Promise.resolve(actualChange));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.isNotOk(result.newStatus);
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index f9a8401..497962f 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -56,7 +56,7 @@
 import {Deduping} from '../../api/reporting';
 import {extractMentionedUsers, getUserId} from '../../utils/account-util';
 import {SpecialFilePath} from '../../constants/constants';
-import {AccountsModel} from '../accounts-model/accounts-model';
+import {AccountsModel} from '../accounts/accounts-model';
 import {
   distinctUntilChanged,
   map,
@@ -432,7 +432,7 @@
     threads.filter(t => !isNewThread(t) && isDraftThread(t))
   );
 
-  public readonly threadsWithSuggestions$ = select(
+  public readonly threadsWithUnappliedSuggestions$ = select(
     combineLatest([this.threads$, this.changeModel.latestPatchNum$]),
     ([threads, latestPs]) =>
       threads.filter(
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 0d3df42..b3dbc08 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -28,7 +28,7 @@
 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 {accountsModelToken} from '../accounts/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';
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 8e9bead..880bcd0 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -89,6 +89,11 @@
 
   public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
 
+  public suggestionsPlugins$ = select(
+    this.state$,
+    state => state.suggestionsPlugins
+  );
+
   public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
 
   constructor() {
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index cd6a66a..4973307 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {from, of, Observable} from 'rxjs';
-import {filter, switchMap} from 'rxjs/operators';
+import {filter, switchMap, tap} from 'rxjs/operators';
 import {
   DiffPreferencesInfo as DiffPreferencesInfoAPI,
   DiffViewMode,
@@ -13,6 +13,7 @@
   AccountCapabilityInfo,
   AccountDetailInfo,
   EditPreferencesInfo,
+  EmailInfo,
   PreferencesInfo,
   TopMenuItemInfo,
 } from '../../types/common';
@@ -48,6 +49,7 @@
    * `account` is known, then use `accountLoaded` below.
    */
   account?: AccountDetailInfo;
+  emails?: EmailInfo[];
   /**
    * Starts as `false` and switches to `true` after the first `getAccount` call.
    * A common use case for this is to wait with loading or doing something until
@@ -82,6 +84,15 @@
     userState => userState.account
   );
 
+  readonly emails$: Observable<EmailInfo[] | undefined> = select(
+    this.state$,
+    userState => userState.emails
+  ).pipe(
+    tap(emails => {
+      if (emails === undefined) this.loadEmails();
+    })
+  );
+
   /**
    * Only emits once we have tried to actually load the account. Note that
    * this does not initially emit a value.
@@ -148,12 +159,8 @@
     super({
       accountLoaded: false,
     });
+    this.loadAccount();
     this.subscriptions = [
-      from(this.restApiService.getAccount()).subscribe(
-        (account?: AccountDetailInfo) => {
-          this.setAccount(account);
-        }
-      ),
       this.loadedAccount$
         .pipe(
           switchMap(account => {
@@ -261,4 +268,22 @@
   setAccount(account?: AccountDetailInfo) {
     this.updateState({account, accountLoaded: true});
   }
+
+  private setAccountEmails(emails?: EmailInfo[]) {
+    this.updateState({emails});
+  }
+
+  loadAccount(noCache?: boolean) {
+    if (noCache) this.restApiService.invalidateAccountsDetailCache();
+    return this.restApiService.getAccount().then(account => {
+      this.setAccount(account);
+    });
+  }
+
+  loadEmails(noCache?: boolean) {
+    if (noCache) this.restApiService.invalidateAccountsEmailCache();
+    return this.restApiService.getAccountEmails().then(emails => {
+      this.setAccountEmails(emails);
+    });
+  }
 }
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 9fa2e89..21fab53 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -454,6 +454,14 @@
     },
   },
   {
+    name: 'highlightjs-epp',
+    license: {
+      name: 'highlightjs-epp',
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
     name: 'highlightjs-structured-text',
     license: {
       name: 'highlightjs-structured-text',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 73816d2..dea0e8b 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -35,8 +35,9 @@
     "@webcomponents/shadycss": "^1.11.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "highlight.js": "^11.9.0",
-    "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
-    "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
+    "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba",
+    "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9",
+    "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e",
     "immer": "^9.0.21",
     "lit": "^3.1.2",
     "polymer-bridges": "file:../../polymer-bridges",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 40517ac..8536492 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -34,7 +34,7 @@
 import {
   AccountsModel,
   accountsModelToken,
-} from '../models/accounts-model/accounts-model';
+} from '../models/accounts/accounts-model';
 import {
   DashboardViewModel,
   dashboardViewModelToken,
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index b59d7a8b3..7f3f9f5 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -22,4 +22,7 @@
   ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
   ML_SUGGESTED_EDIT_V2 = 'UiFeature__ml_suggested_edit_v2',
   REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
+  COMMENT_AUTOCOMPLETION = 'UiFeature__comment_autocompletion_enabled',
+  GR_TEXTAREA = 'UiFeature__gr_textarea_enabled',
+  SAVE_PROJECT_CONFIG_FOR_REVIEW = 'UiFeature__save_project_config_for_review',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 6df2c67..e175228 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -55,7 +55,7 @@
   /**
    * Finish named timer and report it to server.
    */
-  timeEnd(name: Timing, eventDetails?: EventDetails): void;
+  timeEnd(name: Timing, eventDetails?: EventDetails): number;
   /**
    * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
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 1eb3bc2..781f370 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -770,23 +770,26 @@
   /**
    * Finish named timer and report it to server.
    */
-  timeEnd(name: Timing, eventDetails?: EventDetails) {
+  timeEnd(name: Timing, eventDetails?: EventDetails): number {
     if (!hasOwnProperty(this._baselines, name)) {
-      return;
+      return 0;
     }
-    const baseTime = this._baselines[name];
+    const begin = this._baselines[name];
     delete this._baselines[name];
-    this._reportTiming(name, now() - baseTime, eventDetails);
+    const end = now();
+    const elapsed = end - begin;
+    this._reportTiming(name, elapsed, eventDetails);
 
     // Finalize the interval. Either from a registered start mark or
     // the navigation start time (if baseTime is 0).
-    if (baseTime !== 0) {
+    if (begin !== 0) {
       window.performance.measure(name, `${name}-start`);
     } else {
       // Microsoft Edge does not handle the 2nd param correctly
       // (if undefined).
       window.performance.measure(name);
     }
+    return elapsed;
   }
 
   /**
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index fb1f0c3..7cb777a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -71,5 +71,5 @@
   setRepoName: () => {},
   setChangeId: () => {},
   time: () => {},
-  timeEnd: () => {},
+  timeEnd: () => 0,
 };
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 11644f0..b7eb7ac 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
@@ -348,6 +348,22 @@
     });
   }
 
+  saveRepoConfigForReview(
+    repo: RepoName,
+    config: ConfigInput
+  ): Promise<ChangeInfo | undefined> {
+    const url = `/projects/${encodeURIComponent(repo)}/config:review`;
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: config,
+      }),
+      url,
+      anonymizedUrl: '/projects/*/config',
+      reportServerError: true,
+    }) as unknown as Promise<ChangeInfo | undefined>;
+  }
+
   runRepoGC(repo: RepoName): Promise<Response> {
     const encodeName = encodeURIComponent(repo);
     return this._restApiHelper.fetch({
@@ -1277,7 +1293,6 @@
 
     // This list MUST be kept in sync with
     // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
-    // This list MUST be kept in sync with getResponseFormatOptions
     const options = [
       ListChangesOption.ALL_COMMITS,
       ListChangesOption.ALL_REVISIONS,
@@ -1301,35 +1316,6 @@
     return options;
   }
 
-  async getResponseFormatOptions(): Promise<string[]> {
-    const config = await this.getConfig(false);
-
-    // This list MUST be kept in sync with
-    // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
-    // This list MUST be kept in sync with getChangeOptions
-    const options = [
-      'ALL_COMMITS',
-      'ALL_REVISIONS',
-      'CHANGE_ACTIONS',
-      'DETAILED_LABELS',
-      'DETAILED_ACCOUNTS',
-      'DOWNLOAD_COMMANDS',
-      'MESSAGES',
-      'REVIEWER_UPDATES',
-      'SUBMITTABLE',
-      'WEB_LINKS',
-      'SKIP_DIFFSTAT',
-      'SUBMIT_REQUIREMENTS',
-    ];
-    if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) {
-      options.push('PARENTS');
-    }
-    if (config?.receive?.enable_signed_push) {
-      options.push('PUSH_CERTIFICATES');
-    }
-    return options;
-  }
-
   /**
    * @param optionsHex list changes options in hex
    */
@@ -1595,6 +1581,10 @@
     this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
   }
 
+  invalidateAccountsEmailCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/emails');
+  }
+
   getGroups(filter: string, groupsPerPage: number, offset?: number) {
     const url = this._getGroupsUrl(filter, groupsPerPage, offset);
 
@@ -2043,6 +2033,20 @@
     });
   }
 
+  getSaveReviewChangeOptions(): string[] {
+    const options = [
+      'CHANGE_ACTIONS',
+      'DETAILED_LABELS',
+      'DETAILED_ACCOUNTS',
+      'MESSAGES',
+      'REVIEWER_UPDATES',
+      'SUBMITTABLE',
+      'SKIP_DIFFSTAT',
+      'SUBMIT_REQUIREMENTS',
+    ];
+    return options;
+  }
+
   async saveChangeReview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -2051,7 +2055,7 @@
     fetchDetail?: boolean
   ): Promise<ReviewResult | undefined> {
     if (fetchDetail) {
-      review.response_format_options = await this.getResponseFormatOptions();
+      review.response_format_options = this.getSaveReviewChangeOptions();
     }
     const promises: [Promise<void>, Promise<string>] = [
       this.awaitPendingDiffDrafts(),
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 947952c..66a60a6 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
@@ -542,6 +542,7 @@
   invalidateReposCache(): void;
   invalidateAccountsCache(): void;
   invalidateAccountsDetailCache(): void;
+  invalidateAccountsEmailCache(): void;
   removeFromAttentionSet(
     changeNum: NumericChangeId,
     user: AccountId,
@@ -644,6 +645,10 @@
   deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
   deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
   saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+  saveRepoConfigForReview(
+    repo: RepoName,
+    config: ConfigInput
+  ): Promise<ChangeInfo | undefined>;
 
   getRelatedChanges(
     changeNum: NumericChangeId,
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 77f2498..c1fe4ef 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -442,6 +442,7 @@
   invalidateGroupsCache(): void {},
   invalidateReposCache(): void {},
   invalidateAccountsDetailCache(): void {},
+  invalidateAccountsEmailCache(): void {},
   probePath(): Promise<boolean> {
     return Promise.resolve(true);
   },
@@ -518,6 +519,9 @@
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  saveRepoConfigForReview(): Promise<ChangeInfo | undefined> {
+    throw new Error('saveRepoConfigForReview() not implemented by mock.');
+  },
   saveWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index cba3a05..7d666e8 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -98,6 +98,7 @@
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {
   DetailedLabelInfo,
+  PatchSetNumber,
   QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
@@ -428,6 +429,7 @@
     owner: createAccountWithId(),
     // This is documented as optional, but actually always set.
     reviewers: createReviewers(),
+    current_revision_number: 1 as PatchSetNumber,
     ...partial,
   };
 }
@@ -710,6 +712,7 @@
     email_strategy: EmailStrategy.ENABLED,
     allow_browser_notifications: true,
     allow_suggest_code_while_commenting: true,
+    allow_autocompleting_comments: true,
   };
 }
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 4dd965c..720899f 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -986,6 +986,7 @@
   can_add?: boolean;
   can_add_tags?: boolean;
   config_visible?: boolean;
+  require_change_for_config_update?: boolean;
   groups: RepoAccessGroups;
   config_web_links: WebLinkInfo[];
 }
@@ -1344,6 +1345,7 @@
   email_format?: EmailFormat;
   allow_browser_notifications?: boolean;
   allow_suggest_code_while_commenting?: boolean;
+  allow_autocompleting_comments?: boolean;
   diff_page_sidebar?: DiffPageSidebar;
 }
 
diff --git a/polygerrit-ui/app/utils/autocomplete-cache.ts b/polygerrit-ui/app/utils/autocomplete-cache.ts
new file mode 100644
index 0000000..b880ccd
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export interface AutocompletionContext {
+  draftContent: string;
+  draftContentLength?: number;
+  commentCompletion: string;
+  commentCompletionLength?: number;
+
+  isFullCommentPrediction?: boolean;
+  draftInSyncWithSuggestionLength?: number;
+  modelVersion?: string;
+  outcome?: number;
+  requestDurationMs?: number;
+
+  commentId?: string;
+  commentNumber?: number;
+  filePath?: string;
+  fileExtension?: string;
+
+  similarCharacters?: number;
+  maxSimilarCharacters?: number;
+  acceptedSuggestionsCount?: number;
+  totalAcceptedCharacters?: number;
+  savedDraftLength?: number;
+
+  hasDraftChanged?: boolean;
+}
+
+/**
+ * Caching for autocompleting text, e.g. comments.
+ *
+ * If the user continues typing text that matches the completion hint, then keep the hint.
+ *
+ * If the user backspaces, then continue using previous hint.
+ */
+export class AutocompleteCache {
+  /**
+   * We are using an ordered list instead of a map here, because we want to evict the oldest
+   * entries, if the capacity is exceeded. And we want to prefer newer entries over older
+   * entries, if both match the criteria for being reused.
+   */
+  private cache: AutocompletionContext[] = [];
+
+  constructor(private readonly capacity = 10) {}
+
+  get(content: string): AutocompletionContext | undefined {
+    if (content === '') return undefined;
+    for (let i = this.cache.length - 1; i >= 0; i--) {
+      const cachedContext = this.cache[i];
+      const completionContent = cachedContext.draftContent;
+      const completionHint = cachedContext.commentCompletion;
+      const completionFull = completionContent + completionHint;
+      if (completionContent.length > content.length) continue;
+      if (!completionFull.startsWith(content)) continue;
+      if (completionFull === content) continue;
+      const hint = completionFull.substring(content.length);
+      return {
+        ...cachedContext,
+        draftContent: content,
+        commentCompletion: hint,
+        draftInSyncWithSuggestionLength:
+          content.length - completionContent.length,
+      };
+    }
+    return undefined;
+  }
+
+  set(context: AutocompletionContext) {
+    const index = this.cache.findIndex(
+      c => c.draftContent === context.draftContent
+    );
+    if (index !== -1) {
+      this.cache.splice(index, 1);
+    } else if (this.cache.length >= this.capacity) {
+      this.cache.shift();
+    }
+    this.cache.push(context);
+  }
+}
diff --git a/polygerrit-ui/app/utils/autocomplete-cache_test.ts b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
new file mode 100644
index 0000000..970436b
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AutocompleteCache} from './autocomplete-cache';
+import {assert} from '@open-wc/testing';
+
+suite('AutocompleteCache', () => {
+  let cache: AutocompleteCache;
+
+  setup(() => {
+    cache = new AutocompleteCache();
+  });
+
+  const cacheSet = (draftContent: string, commentCompletion: string) => {
+    cache.set({draftContent, commentCompletion});
+  };
+
+  const assertCacheEqual = (
+    draftContent: string,
+    expectedCommentCompletion?: string
+  ) => {
+    assert.equal(
+      cache.get(draftContent)?.commentCompletion,
+      expectedCommentCompletion
+    );
+  };
+
+  test('should get and set values', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foo', 'bar');
+  });
+
+  test('should return undefined for empty content string', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('', undefined);
+  });
+
+  test('should return a value, if completion content+hint start with content', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foo', 'bar');
+    assertCacheEqual('foob', 'ar');
+    assertCacheEqual('fooba', 'r');
+    assertCacheEqual('foobar', undefined);
+  });
+
+  test('should not return a value, if content is shorter than completion content', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('f', undefined);
+    assertCacheEqual('fo', undefined);
+  });
+
+  test('should not get values that are not set', () => {
+    assertCacheEqual('foo', undefined);
+  });
+
+  test('should not return an empty completion, if content equals completion content+hint', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foobar', undefined);
+  });
+
+  test('skips over the first entry, but returns the second entry', () => {
+    cacheSet('foobar', 'bang');
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foobar', 'bang');
+  });
+
+  test('replaces entries', () => {
+    cacheSet('foo', 'bar');
+    cacheSet('foo', 'baz');
+    assertCacheEqual('foo', 'baz');
+  });
+
+  test('prefers newer entries, but also returns older entries', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foob', 'ar');
+    cacheSet('foob', 'arg');
+    assertCacheEqual('foob', 'arg');
+    assertCacheEqual('foo', 'bar');
+  });
+
+  test('capacity', () => {
+    cache = new AutocompleteCache(1);
+    cacheSet('foo', 'bar');
+    cacheSet('boom', 'bang');
+    assertCacheEqual('foo', undefined);
+  });
+});
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
index eca528e..3e41e61 100644
--- a/polygerrit-ui/app/utils/deep-util.ts
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -82,7 +82,7 @@
 /**
  * @param obj Object
  */
-export function deepClone(obj?: object) {
-  if (!obj) return undefined;
-  return JSON.parse(JSON.stringify(obj));
+export function deepClone<T>(obj: T): T {
+  if (!obj) throw new Error('undefined object for deepClone');
+  return JSON.parse(JSON.stringify(obj)) as T;
 }
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 03728a0..04e8c19 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -437,13 +437,16 @@
   if (!isElementTarget(rootTarget)) return false;
   const tagName = rootTarget.tagName;
   const type = rootTarget.getAttribute('type');
+  const editable = !!(rootTarget as HTMLElement).isContentEditable;
 
   if (
+    editable ||
     // Suppress shortcuts on <input> and <textarea>, but not on
     // checkboxes, because we want to enable workflows like 'click
     // mark-reviewed and then press ] to go to the next file'.
     (tagName === 'INPUT' && type !== 'checkbox') ||
     tagName === 'TEXTAREA' ||
+    tagName === 'GR-TEXTAREA' ||
     (e.key === 'Enter' &&
       (tagName === 'A' ||
         tagName === 'BUTTON' ||
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index fe185be..7deae3d 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -309,6 +309,14 @@
       });
     });
 
+    test('suppress shortcut event from <div contenteditable>', async () => {
+      const el = document.createElement('div');
+      el.setAttribute('contenteditable', '');
+      await keyEventOn(el, e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
     test('suppress shortcut event from <input>', async () => {
       await keyEventOn(document.createElement('input'), e => {
         assert.isTrue(shouldSuppress(e));
@@ -321,6 +329,12 @@
       });
     });
 
+    test('suppress shortcut event from <gr-textarea>', async () => {
+      await keyEventOn(document.createElement('gr-textarea'), e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
     test('do not suppress shortcut event from checkbox <input>', async () => {
       const inputEl = document.createElement('input');
       inputEl.setAttribute('type', 'checkbox');
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 81dcde1..abc5529 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -115,3 +115,42 @@
     fileName: fileNameSection,
   };
 }
+
+/**
+ * Computes the Levenshtein edit distance between two strings.
+ */
+export function levenshteinDistance(str1: string, str2: string): number {
+  const m = str1.length;
+  const n = str2.length;
+
+  // Create a matrix to store edit distances
+  const dp: number[][] = Array.from({length: m + 1}, () =>
+    Array(n + 1).fill(0)
+  );
+
+  // Initialize first row and column with base cases
+  for (let i = 0; i <= m; i++) {
+    dp[i][0] = i;
+  }
+  for (let j = 0; j <= n; j++) {
+    dp[0][j] = j;
+  }
+
+  // Calculate edit distances for all substrings
+  for (let i = 1; i <= m; i++) {
+    for (let j = 1; j <= n; j++) {
+      if (str1[i - 1] === str2[j - 1]) {
+        dp[i][j] = dp[i - 1][j - 1];
+      } else {
+        dp[i][j] = Math.min(
+          dp[i - 1][j] + 1, // Deletion
+          dp[i][j - 1] + 1, // Insertion
+          dp[i - 1][j - 1] + 1 // Substitution
+        );
+      }
+    }
+  }
+
+  // Return the final edit distance
+  return dp[m][n];
+}
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index b2f722f..a8f10e2 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -610,18 +610,24 @@
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
   integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
 
-"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.9.0:
+highlight.js@^11.9.0, "highlight.js@^11.9.0 || ^10.4.1":
   version "11.9.0"
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0"
   integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==
 
-"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
+"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba":
   version "0.0.1"
-  resolved "https://github.com/highlightjs/highlightjs-closure-templates#7922b1e68def8b10199e186bb679600de3ebb711"
+  resolved "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba"
   dependencies:
-    highlight.js "^11.5.0 || ^10.4.1"
+    highlight.js "^11.9.0 || ^10.4.1"
 
-"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text":
+"highlightjs-epp@https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9":
+  version "0.0.1"
+  resolved "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9"
+  dependencies:
+    highlight.js "^11.9.0"
+
+"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e":
   version "1.4.9"
   resolved "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e"
   dependencies:
diff --git a/proto/BUILD b/proto/BUILD
index 7aa761d..624568b 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -23,3 +23,9 @@
     visibility = ["//visibility:public"],
     deps = [":entities_proto"],
 )
+
+cc_proto_library(
+    name = "entities_cc_proto",
+    visibility = ["//visibility:public"],
+    deps = [":entities_proto"],
+)
diff --git a/proto/entities.proto b/proto/entities.proto
index be04e97..3a59f09 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -125,9 +125,10 @@
 }
 
 // Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput.
-// Next ID: 2
+// Next ID: 3
 message ApplyPatchInput {
  optional string patch = 1;
+ optional bool allow_conflicts = 2;
 }
 
 // Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput.
@@ -277,7 +278,7 @@
 // Next ID: 2
 message ObjectId {
   // Hex string representation of the ID.
-  optional string name = 1;
+  optional string name = 1 [default="0000000000000000000000000000000000000000"];
 }
 
 // Serialized form of a continuation token used for pagination.
@@ -289,7 +290,7 @@
 // Proto representation of the User preferences classes
 // Next ID: 4
 message UserPreferences {
-  // Next ID: 24
+  // Next ID: 26
   message GeneralPreferencesInfo {
     // Number of changes to show in a screen.
     optional int32 changes_per_page = 1 [default = 25];
@@ -368,6 +369,8 @@
 
     repeated string change_table = 18;
     optional bool allow_browser_notifications = 19 [default = true];
+    optional bool allow_suggest_code_while_commenting = 24 [default = true];
+    optional bool allow_autocompleting_comments = 25 [default = true];
     optional string diff_page_sidebar = 23 [default = "NONE"];
   }
   optional GeneralPreferencesInfo general_preferences_info = 1;
@@ -453,11 +456,11 @@
     optional Side side = 2 [default = REVISION];
     message Range {
       // 1-based
-      optional int32 start_line = 1;
+      optional int32 start_line = 1 [default = 1];
       // 0-based
       optional int32 start_char = 2;
       // 1-based
-      optional int32 end_line = 3;
+      optional int32 end_line = 3 [default = 1];
       // 0-based
       optional int32 end_char = 4;
     }
@@ -466,7 +469,7 @@
     // number is identical to the range's end line.
     optional Range position_range = 3;
     // 1-based
-    optional int32 line_number = 4;
+    optional int32 line_number = 4 [default = 1];
   }
 
   // If not set, the comment is on the patchset level.
@@ -488,4 +491,4 @@
   optional fixed64 written_on_millis = 11;
   // Required.
   optional string server_id = 12;
-}
\ No newline at end of file
+}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 642ef474..432d088 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -63,6 +63,7 @@
 el = text/x-common-lisp
 elm = text/x-elm
 ejs = application/x-ejs
+epp = application/x-epp
 erb = application/x-erb
 erl = text/x-erlang
 es6 = text/jsx
diff --git a/tools/BUILD b/tools/BUILD
index ed55a4f..dc40427 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -4,9 +4,18 @@
     "default_java_toolchain",
 )
 load("@rules_java//java:defs.bzl", "java_package_configuration")
+load("@rules_proto//proto:defs.bzl", "proto_lang_toolchain")
 
 exports_files(["nongoogle.bzl"])
 
+proto_lang_toolchain(
+    name = "protoc_java_toolchain",
+    command_line = "--java_out=%s",
+    progress_message = "Generating Java proto_library %{label}",
+    runtime = "@protobuf-java//jar",
+    toolchain_type = "@rules_java//java/proto:toolchain_type",
+)
+
 [default_java_toolchain(
     name = "error_prone_warnings_toolchain_java" + VERSION,
     configuration = dict(),
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 4792de2..52fa1dd 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,7 +14,8 @@
 
 # War packaging.
 
-load("//tools:deps.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_GSON_VERSION", "AUTO_VALUE_VERSION")
+load("//tools:deps.bzl", "AUTO_VALUE_GSON_VERSION")
+load("//tools:nongoogle.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_VERSION")
 
 jar_filetype = [".jar"]
 
diff --git a/tools/defs.bzl b/tools/defs.bzl
index 672c6f9..3ebc7dc 100644
--- a/tools/defs.bzl
+++ b/tools/defs.bzl
@@ -1,11 +1,25 @@
-load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+"""
+Bazel definitions for tools.
+"""
+
+load("@bazel_features//:deps.bzl", "bazel_features_deps")
+load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies")
+load("@toolchains_protoc//protoc:repositories.bzl", "rules_protoc_dependencies")
+load("@toolchains_protoc//protoc:toolchain.bzl", "protoc_toolchains")
 
 def gerrit_init():
     """
     Initialize the WORKSPACE for gerrit targets
     """
-    protobuf_deps()
+    rules_protoc_dependencies()
 
-    native.register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
+    rules_proto_dependencies()
 
-    native.register_toolchains("//tools:error_prone_warnings_toolchain_java21_definition")
+    bazel_features_deps()
+
+    protoc_toolchains(
+        name = "toolchains_protoc_hub",
+        version = "v25.3",
+    )
+
+    native.register_toolchains("//tools:all")
diff --git a/tools/deps.bzl b/tools/deps.bzl
index d056483..bc37010 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -11,9 +11,6 @@
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
 OW2_VERS = "9.2"
-AUTO_COMMON_VERSION = "1.2.1"
-AUTO_FACTORY_VERSION = "1.0.1"
-AUTO_VALUE_VERSION = "1.10.4"
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
@@ -90,12 +87,6 @@
     )
 
     maven_jar(
-        name = "gson",
-        artifact = "com.google.code.gson:gson:2.9.0",
-        sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
-    )
-
-    maven_jar(
         name = "caffeine",
         artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
         sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842",
@@ -283,36 +274,6 @@
     )
 
     maven_jar(
-        name = "auto-common",
-        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
-        sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
-    )
-
-    maven_jar(
-        name = "auto-factory",
-        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
-        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
-    )
-
-    maven_jar(
-        name = "auto-service-annotations",
-        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
-        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
-    )
-
-    maven_jar(
-        name = "auto-value",
-        artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-        sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
-    )
-
-    maven_jar(
-        name = "auto-value-annotations",
-        artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-        sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
-    )
-
-    maven_jar(
         name = "auto-value-gson-runtime",
         artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
         sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index a1f383c..603e6c8 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.10.1-SNAPSHOT</version>
+  <version>3.11.0-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 edc39c7..241d59b 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.10.1-SNAPSHOT</version>
+  <version>3.11.0-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 d1a098e..00132bd 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.10.1-SNAPSHOT</version>
+  <version>3.11.0-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 8b46ccd..5a21ab7 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.10.1-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index ac3f668..91caf31 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -7,6 +7,12 @@
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
+AUTO_COMMON_VERSION = "1.2.2"
+
+AUTO_FACTORY_VERSION = "1.0.1"
+
+AUTO_VALUE_VERSION = "1.10.4"
+
 GUAVA_VERSION = "33.0.0-jre"
 
 GUAVA_BIN_SHA1 = "161ba27964a62f241533807a46b8711b13c1d94b"
@@ -18,27 +24,43 @@
 def archive_dependencies():
     return [
         {
-            "name": "com_google_protobuf",
-            "sha256": "9bd87b8280ef720d3240514f884e56a712f2218f0d693b48050c836028940a42",
-            "strip_prefix": "protobuf-25.1",
-            "urls": [
-                "https://github.com/protocolbuffers/protobuf/archive/v25.1.tar.gz",
-            ],
-        },
-        {
             "name": "platforms",
             "urls": [
-                "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
-                "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+                "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz",
+                "https://github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz",
             ],
-            "sha256": "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
+            "sha256": "218efe8ee736d26a3572663b374a253c012b716d8af0c07e842e82f238a0a7ee",
+        },
+        {
+            "name": "bazel_features",
+            "strip_prefix": "bazel_features-1.11.0",
+            "urls": [
+                "https://github.com/bazel-contrib/bazel_features/releases/download/v1.11.0/bazel_features-v1.11.0.tar.gz",
+            ],
+            "sha256": "2cd9e57d4c38675d321731d65c15258f3a66438ad531ae09cb8bb14217dc8572",
         },
         {
             "name": "rules_java",
             "urls": [
-                "https://github.com/bazelbuild/rules_java/releases/download/7.3.1/rules_java-7.3.1.tar.gz",
+                "https://github.com/bazelbuild/rules_java/releases/download/7.6.1/rules_java-7.6.1.tar.gz",
             ],
-            "sha256": "4018e97c93f97680f1650ffd2a7530245b864ac543fd24fae8c02ba447cb2864",
+            "sha256": "f8ae9ed3887df02f40de9f4f7ac3873e6dd7a471f9cddf63952538b94b59aeb3",
+        },
+        {
+            "name": "rules_proto",
+            "strip_prefix": "rules_proto-6.0.0",
+            "urls": [
+                "https://github.com/bazelbuild/rules_proto/releases/download/6.0.0/rules_proto-6.0.0.tar.gz",
+            ],
+            "sha256": "303e86e722a520f6f326a50b41cfc16b98fe6d1955ce46642a5b7a67c11c0f5d",
+        },
+        {
+            "name": "toolchains_protoc",
+            "strip_prefix": "toolchains_protoc-0.3.0",
+            "urls": [
+                "https://github.com/aspect-build/toolchains_protoc/releases/download/v0.3.0/toolchains_protoc-v0.3.0.tar.gz",
+            ],
+            "sha256": "117af61ee2f1b9b014dcac7c9146f374875551abb8a30e51d1b3c5946d25b142",
         },
         {
             "name": "ubuntu2204_jdk17",
@@ -182,6 +204,36 @@
     # no concern about version skew.
 
     maven_jar(
+        name = "auto-common",
+        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+        sha1 = "9d38f10e22411681cf1d1ee3727e002af19f2c9e",
+    )
+
+    maven_jar(
+        name = "auto-factory",
+        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+    )
+
+    maven_jar(
+        name = "auto-service-annotations",
+        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+    )
+
+    maven_jar(
+        name = "auto-value",
+        artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+        sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
+    )
+
+    maven_jar(
+        name = "auto-value-annotations",
+        artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+        sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
+    )
+
+    maven_jar(
         name = "error-prone-annotations",
         artifact = "com.google.errorprone:error_prone_annotations:2.22.0",
         sha1 = "bfb9e4281a4cea34f0ec85b3acd47621cfab35b4",
@@ -252,6 +304,18 @@
         sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
     )
 
+    maven_jar(
+        name = "gson",
+        artifact = "com.google.code.gson:gson:2.10.1",
+        sha1 = "b3add478d4382b78ea20b1671390a858002feb6c",
+    )
+
+    maven_jar(
+        name = "protobuf-java",
+        artifact = "com.google.protobuf:protobuf-java:3.25.3",
+        sha1 = "d3200261955f3298e0d85c9892201e70492ce8eb",
+    )
+
     # Test-only dependencies below.
     maven_jar(
         name = "cglib-3_2",
diff --git a/tools/run_gjf.sh b/tools/run_gjf.sh
new file mode 100755
index 0000000..c6eea0f
--- /dev/null
+++ b/tools/run_gjf.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+#
+# Copyright (C) 2024 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+GJF_VERSION=$(grep -o "^VERSION=.*$" tools/setup_gjf.sh | grep -o "[0-9][0-9]*\.[0-9][0-9]*")
+GJF=$(find 'tools/format' -regex '.*/google-java-format-[0-9][0-9]*\.[0-9][0-9]*')
+if [ ! -f "$GJF" ]; then
+  ./setup_gjf.sh
+  GJF=$(find 'tools/format' -regex '.*/google-java-format-[0-9][0-9]*\.[0-9][0-9]*')
+fi
+echo 'Running google-java-format check...'
+git show --diff-filter=AM --name-only --pretty="" HEAD | grep java$ | xargs $GJF -r
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
index 8e2b57b..303aa0c 100755
--- a/tools/setup_gjf.sh
+++ b/tools/setup_gjf.sh
@@ -20,20 +20,13 @@
 VERSION=${1:-1.7}
 
 case "$VERSION" in
-1.3)
-    SHA1="a73cfe6f9af01bd6ff150c0b50c9d620400f784c"
-    ;;
-1.5)
-    SHA1="b1f79e4d39a3c501f07c0ce7e8b03ac6964ed1f1"
-    ;;
-1.6)
-    SHA1="02b3e84e52d2473e2c4868189709905a51647d03"
-    ;;
 1.7)
     SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702"
+    TAG_PREFIX="google-java-format-"
     ;;
 1.22.0)
     SHA1="693d8fd04656886a2287cfe1d7a118c4697c3a57"
+    TAG_PREFIX="v"
     ;;
 *)
     echo "unknown google-java-format version: $VERSION"
@@ -51,7 +44,7 @@
 mkdir -p "$dir"
 
 name="google-java-format-$VERSION-all-deps.jar"
-url="https://github.com/google/google-java-format/releases/download/v$VERSION/$name"
+url="https://github.com/google/google-java-format/releases/download/$TAG_PREFIX$VERSION/$name"
 "$root/tools/download_file.py" -o "$dir/$name" -u "$url" -v "$SHA1"
 
 launcher="$dir/google-java-format-$VERSION"
diff --git a/version.bzl b/version.bzl
index 0c63937..181159e 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.10.1-SNAPSHOT"
+GERRIT_VERSION = "3.11.0-SNAPSHOT"