Merge "Put AI Suggestion option under v2 feature flag"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3e75a53..7d531ff 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -97,7 +97,7 @@
 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:
 
 * link:#administrators[Administrators]
 * link:#service_users[Service Users]
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/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/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 50885b6..6e14415 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6985,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.
@@ -7224,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.)
@@ -7358,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-projects.txt b/Documentation/rest-api-projects.txt
index ab78ff0..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
 --
@@ -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/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/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index fad3aa8..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.
@@ -434,7 +437,7 @@
   private Id changeId;
 
   /** ServerId of the Gerrit instance that has created the change */
-  private String serverId;
+  @Nullable private String serverId;
 
   /** Globally assigned unique identifier of the change */
   private Key changeKey;
@@ -545,7 +548,8 @@
    * ServerId of the Gerrit instance that created the change. It could be null when the change is
    * not fetched from NoteDb but obtained through protobuf deserialisation.
    */
-  public @Nullable String getServerId() {
+  @Nullable
+  public String getServerId() {
     return serverId;
   }
 
@@ -607,6 +611,7 @@
     return originalSubject != null ? originalSubject : subject;
   }
 
+  @Nullable
   public String getOriginalSubjectOrNull() {
     return originalSubject;
   }
@@ -652,6 +657,7 @@
     originalSubject = null;
   }
 
+  @Nullable
   public String getSubmissionId() {
     return submissionId;
   }
@@ -684,6 +690,7 @@
     return isAbandoned() || isMerged();
   }
 
+  @Nullable
   public String getTopic() {
     return topic;
   }
@@ -720,10 +727,12 @@
     this.revertOf = revertOf;
   }
 
+  @Nullable
   public Id getRevertOf() {
     return this.revertOf;
   }
 
+  @Nullable
   public PatchSet.Id getCherryPickOf() {
     return cherryPickOf;
   }
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/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/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/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
index bab7bb2..f4a66a0 100644
--- a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
@@ -2,7 +2,7 @@
 
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.common.ConvertibleToProto;
-import com.google.protobuf.MessageLite;
+import com.google.protobuf.Message;
 
 /**
  * An extension to {@link ProtoConverter} that enforces the Entity class and the Proto class to stay
@@ -21,7 +21,7 @@
  * setters, there is no need to explicitly test your safe converter.
  */
 @Immutable
-public interface SafeProtoConverter<P extends MessageLite, C> extends ProtoConverter<P, C> {
+public interface SafeProtoConverter<P extends Message, C> extends ProtoConverter<P, C> {
 
   Class<P> getProtoClass();
 
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/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/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/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/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 912abc4..4b2f6d2 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -17,7 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.converter.ProtoConverter;
+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;
@@ -36,7 +36,8 @@
  */
 public final class UserPreferencesConverter {
   public enum GeneralPreferencesInfoConverter
-      implements ProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
+      implements
+          SafeProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
     GENERAL_PREFERENCES_INFO_CONVERTER;
 
     @Override
@@ -118,6 +119,14 @@
       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();
     }
@@ -180,6 +189,12 @@
       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;
     }
@@ -212,10 +227,20 @@
           proto.hasTarget() ? proto.getTarget().trim() : null,
           proto.hasId() ? proto.getId().trim() : null);
     }
+
+    @Override
+    public Class<UserPreferences.GeneralPreferencesInfo> getProtoClass() {
+      return UserPreferences.GeneralPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<GeneralPreferencesInfo> getEntityClass() {
+      return GeneralPreferencesInfo.class;
+    }
   }
 
   public enum DiffPreferencesInfoConverter
-      implements ProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
+      implements SafeProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
     DIFF_PREFERENCES_INFO_CONVERTER;
 
     @Override
@@ -295,10 +320,20 @@
     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 enum EditPreferencesInfoConverter
-      implements ProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
+      implements SafeProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
     EDIT_PREFERENCES_INFO_CONVERTER;
 
     @Override
@@ -347,6 +382,16 @@
     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/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/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index 2bbd261..17cc5a0 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -301,7 +301,9 @@
       size += JavaWeights.REFERENCE + (c.getTopic() == null ? 0 : c.getTopic().length());
       size +=
           JavaWeights.REFERENCE
-              + (c.getOriginalSubject().equals(c.getSubject()) ? 0 : c.getSubject().length());
+              + (c.getOriginalSubject().equals(c.getSubject())
+                  ? 0
+                  : c.getOriginalSubject().length());
       size +=
           JavaWeights.REFERENCE + (c.getSubmissionId() == null ? 0 : c.getSubmissionId().length());
       size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // isPrivate;
@@ -309,7 +311,7 @@
       size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // reviewStarted;
       size += JavaWeights.REFERENCE + (c.getRevertOf() == null ? 0 : GerritWeights.CHANGE_NUM);
       size +=
-          JavaWeights.REFERENCE + (c.getCherryPickOf() == null ? 0 : GerritWeights.PACTCH_SET_ID);
+          JavaWeights.REFERENCE + (c.getCherryPickOf() == null ? 0 : GerritWeights.PATCH_SET_ID);
       return size;
     }
 
@@ -343,7 +345,7 @@
     public static final int KEY_INT = JavaWeights.OBJECT + JavaWeights.INT; // IntKey
     public static final int CHANGE_NUM = KEY_INT;
     public static final int ACCOUNT_ID = KEY_INT;
-    public static final int PACTCH_SET_ID =
+    public static final int PATCH_SET_ID =
         JavaWeights.OBJECT
             + (JavaWeights.REFERENCE + GerritWeights.CHANGE_NUM) // PatchSet.Id.changeId
             + JavaWeights.INT; // PatchSet.Id patch_num;
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/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/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 41faf22..8d5247d 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -56,6 +56,7 @@
 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;
 
@@ -171,6 +172,10 @@
               revWalk,
               applyResult.getTreeId(),
               commitMessage);
+      if (changeInfo.containsGitConflicts == null
+          && applyResult.getErrors().stream().anyMatch(Error::isGitConflict)) {
+        changeInfo.containsGitConflicts = true;
+      }
       return Response.ok(changeInfo);
     }
   }
@@ -205,7 +210,7 @@
     }
     String commitMessage =
         ApplyPatchUtil.buildCommitMessage(
-            messageWithNoFooters, footerLines, input.patch.patch, resultPatch, errors);
+            messageWithNoFooters, footerLines, input.patch, resultPatch, errors);
 
     boolean changeIdRequired =
         projectCache
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 b0e52e4..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;
@@ -440,6 +440,7 @@
         String commitMessage = getCommitMessage(input.subject, me);
 
         CodeReviewCommit c;
+        boolean hasGitConflicts = false;
         if (input.merge != null) {
           // create a merge commit
           c =
@@ -460,9 +461,32 @@
           }
         } 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(
@@ -525,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());
@@ -663,43 +688,6 @@
             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,
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/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..aaf8d02
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.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.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.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 {
+    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..12d1189 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -27,19 +27,13 @@
 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,27 +43,14 @@
 @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
@@ -77,15 +58,6 @@
       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);
-
     if (input == null) {
       input = new LabelDefinitionInput();
     }
@@ -93,22 +65,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..b946958 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -26,12 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.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 +34,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 +44,42 @@
 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");
+    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 +87,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..fcd72a3 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -14,84 +14,49 @@
 
 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.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 {
     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/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..a0f174d 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -34,6 +34,7 @@
 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.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -47,15 +48,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 +65,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 +75,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 +85,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 +98,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 +110,7 @@
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -127,72 +124,74 @@
   }
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, AuthException {
     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 +301,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..698cf58 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -14,24 +14,18 @@
 
 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.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 +33,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 {
     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..5aea2c5 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -15,24 +15,28 @@
 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.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 +53,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 +75,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 +141,153 @@
       }
     }
     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.
+   *
+   * <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 {
+    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 doesn't do any 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 {
+    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 +307,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 +348,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..1553eda 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -24,18 +24,12 @@
 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 +37,36 @@
 
 @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);
-
     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..ad8bc8a 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -38,7 +38,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 +47,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,29 +58,26 @@
     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
@@ -101,20 +96,14 @@
     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..7eb2665 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -24,19 +24,13 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementJson;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -48,42 +42,21 @@
 @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);
-
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -92,16 +65,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 +83,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/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index cf3948a..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,7 +94,7 @@
     try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
       GroupReference admins = createGroupReference("Administrators");
       GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
-      GroupReference blockedUsers = createGroupReference("Blocked Users");
+      GroupReference blockedUsers = createGroupReference(BLOCKED_USERS);
 
       AllProjectsInput allProjectsInput =
           AllProjectsInput.builder()
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/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/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/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/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/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..2ce8f78 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 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;
@@ -102,6 +103,10 @@
     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);
   }
@@ -111,10 +116,16 @@
     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/PutDescriptionIT.java b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
new file mode 100644
index 0000000..befb311
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
@@ -0,0 +1,47 @@
+// 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.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+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");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 2bdbe50..a839b6a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -25,6 +25,7 @@
 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.ResourceConflictException;
@@ -89,12 +90,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
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..f9759ac
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
@@ -0,0 +1,94 @@
+// 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.gerrit.acceptance.AbstractDaemonTest;
+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.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
+  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 6cfb989..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));
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index c18cd55..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;
@@ -120,7 +121,7 @@
   public void setUp() throws Exception {
     admins = adminGroupUuid();
     serviceUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
-    blockedUsers = groupUuid("Blocked Users");
+    blockedUsers = groupUuid(SchemaCreatorImpl.BLOCKED_USERS);
     setUpPermissions();
     setUpChanges();
   }
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/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index df7cd8f..1e03f2d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -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/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index e58757b..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", "Blocked Users", ServiceUserClassifier.SERVICE_USERS);
+        .containsExactly(
+            "Administrators", SchemaCreatorImpl.BLOCKED_USERS, ServiceUserClassifier.SERVICE_USERS);
   }
 }
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/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/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/SafeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
index 42a993d..eb69d53 100644
--- a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
@@ -2,6 +2,7 @@
 
 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;
@@ -10,6 +11,8 @@
 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;
@@ -24,187 +27,260 @@
 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.Ignore;
 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(Parameterized.class)
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  SafeProtoConverterTest.ListSafeProtoConverterTest.class, //
+  SafeProtoConverterTest.PerTypeSafeProtoConverterTest.class, //
+})
 public class SafeProtoConverterTest {
-  @Parameter(0)
-  public SafeProtoConverter<MessageLite, Object> converter;
-
-  @Parameter(1)
-  public String converterName;
-
-  @Parameters(name = "{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<MessageLite, 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);
+  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();
     }
-    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
-  @Ignore("TODO(b/335372403) - implement")
-  public void protoDefaultsKeptOnDoubleConversion() {}
+  @RunWith(Parameterized.class)
+  public static class PerTypeSafeProtoConverterTest {
+    @Parameter(0)
+    public SafeProtoConverter<Message, Object> converter;
 
-  @Nullable
-  private static Object buildObjectWithFullFieldsOrThrow(Class<?> clz) throws Exception {
-    if (clz == null) {
-      return null;
+    @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());
     }
-    Object obj = construct(clz);
-    if (isSimple(clz)) {
+
+    /**
+     * 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;
     }
-    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)));
+
+    /**
+     * 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();
       }
     }
-    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())) {
+    @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;
     }
-    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;
+    @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);
       }
-      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);
+    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/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 3fae3ad..089ceea 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -91,7 +91,7 @@
 
     GroupReference adminsGroup = createGroupReference("Administrators");
     GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
-    GroupReference blockedUsersGroup = createGroupReference("Blocked Users");
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
@@ -165,7 +165,7 @@
   public void createAllProjectsWithoutInitializingDefaultSubmitRequirements() throws Exception {
     GroupReference adminsGroup = createGroupReference("Administrators");
     GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
-    GroupReference blockedUsersGroup = createGroupReference("Blocked Users");
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
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..37cee04 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
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/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index 2faeeda..d6425be 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -45,6 +45,7 @@
 /** <gr-textarea> event when showing a hint */
 export declare interface HintShownEventDetail {
   hint: string;
+  oldValue: string;
 }
 
 /** <gr-textarea> event when a hint was dismissed */
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index c3089a9..1aa4ebe 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -72,6 +72,8 @@
 export declare interface AutocompleteCommentResponse {
   responseCode: ResponseCode;
   completion?: string;
+  modelVersion?: string;
+  outcome?: number;
 }
 
 export declare interface SuggestCodeResponse {
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-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 4e8841e..2251321 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -815,7 +815,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,7 +920,7 @@
       this.repo,
       this.formatRepoConfigForSave(this.repoConfig)
     );
-    this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+    this.originalConfig = deepClone<ConfigInfo>(this.repoConfig);
     this.pluginConfigChanged = false;
     return;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 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-revision-parents/gr-revision-parents.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
index 9cf5423..2dbbd26 100644
--- a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
@@ -3,6 +3,8 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-commit-info/gr-commit-info';
+import '../../shared/gr-button/gr-button';
 import {customElement, state} from 'lit/decorators.js';
 import {css, html, HTMLTemplateResult, LitElement} from 'lit';
 import {resolve} from '../../../models/dependency';
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
index b9aa63d..d3b46ff 100644
--- a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
@@ -167,7 +167,7 @@
        The diff below may not be meaningful and may<br/>
        even be hiding relevant changes.
        <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
-       </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+       </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
     );
   });
 
@@ -183,7 +183,7 @@
             The diff below may not be meaningful and may<br/>
             even be hiding relevant changes.
             <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
-            </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+            </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
     );
   });
 
@@ -235,7 +235,7 @@
        The diff below may not be meaningful and may<br/>
        even be hiding relevant changes.
        <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
-       </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+       </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
     );
   });
 });
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 e63ac8f..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
@@ -32,6 +32,9 @@
   @property({type: Object})
   account?: AccountInfo;
 
+  @property({type: Boolean})
+  showMobile?: boolean;
+
   // Private but used in test
   @state()
   config?: ServerInfo;
@@ -45,6 +48,9 @@
   @state()
   private switchAccountUrl = '';
 
+  // private but used in test
+  @state() feedbackURL = '';
+
   // Private but used in test
   readonly getConfigModel = resolve(this, configModelToken);
 
@@ -56,6 +62,10 @@
       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 {
@@ -103,7 +113,11 @@
       @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}
@@ -135,6 +149,15 @@
       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;
   }
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/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 634b17a..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,7 +3,6 @@
  * 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';
@@ -13,7 +12,10 @@
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {notDeepEqual} from '../../../utils/deep-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 {
@@ -30,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,
@@ -83,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
@@ -109,13 +122,6 @@
     </tr>`;
   }
 
-  loadData() {
-    return this.restApiService.getAccountEmails().then(emails => {
-      this.originalEmails = emails ?? [];
-      this.emails = emails ? [...emails] : [];
-    });
-  }
-
   save() {
     const promises: Promise<unknown>[] = [];
 
@@ -129,24 +135,24 @@
       );
     }
 
-    return Promise.all(promises).then(() => {
-      this.originalEmails = this.emails;
+    return Promise.all(promises).then(async () => {
       this.emailsToRemove = [];
       this.newPreferred = '';
+      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];
     // Don't add project to emailsToRemove if it wasn't in
-    // originalEmails.
-    if (this.originalEmails.includes(email)) this.emailsToRemove.push(email);
+    // 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();
@@ -172,7 +178,8 @@
         this.newPreferred = preferred;
         this.setHasUnsavedChanges();
       } else if (this.emails[i].preferred) {
-        this.emails[i].preferred = false;
+        delete this.emails[i].preferred;
+        this.setHasUnsavedChanges();
         this.requestUpdate();
       }
     }
@@ -184,7 +191,7 @@
 
   private setHasUnsavedChanges() {
     const hasUnsavedChanges =
-      notDeepEqual(this.originalEmails, this.emails) ||
+      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 39c3288..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"
                 >
@@ -239,6 +231,12 @@
     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');
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 56540d0..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;
 
@@ -284,6 +287,7 @@
           </section>
           ${this.renderBrowserNotifications()}
           ${this.renderGenerateSuggestionWhenCommenting()}
+          ${this.renderAiCommentAutocompletion()}
           ${this.renderDefaultBaseForMerges()}
           <section>
             <label class="title" for="relativeDateInChangeTable"
@@ -515,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() {
@@ -561,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 e1ce5cc..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=${() => {
@@ -469,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
@@ -603,7 +605,7 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
+    Promise.all([this.accountInfo.loadData()]);
   }
 
   // private but used in test
@@ -641,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.
@@ -651,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/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 691eec4..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
@@ -10,9 +10,13 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import {getAppContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {isSelf, isServiceUser} from '../../../utils/account-util';
+import {
+  isDetailedAccount,
+  isSelf,
+  isServiceUser,
+} from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {LitElement, css, html, TemplateResult} from 'lit';
@@ -198,15 +202,29 @@
     ];
   }
 
-  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);
-    // 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 details to avoid
-    // displaying incorrect author or committer email.
-    if (account) this.account = Object.assign(account, this.account);
+    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() {
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 c27ca5b..e73aad2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -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,6 +93,12 @@
 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;
@@ -224,7 +234,14 @@
    * An hint for autocompleting the comment message from plugin suggestion
    * providers.
    */
-  @state() autocompleteHint = '';
+  @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()
@@ -440,6 +457,7 @@
         this,
         () => this.getUserModel().preferences$,
         prefs => {
+          this.autocompleteEnabled = !!prefs.allow_autocompleting_comments;
           if (
             this.generateSuggestion !==
             !!prefs.allow_suggest_code_while_commenting
@@ -671,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);
+        }
       `,
     ];
   }
@@ -893,12 +915,71 @@
         rows="4"
         .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
-        autocompleteHint=${this.autocompleteHint}
+        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;
@@ -909,33 +990,20 @@
     // of the textare instead of needing a dedicated property.
     this.messageText = newValue;
 
-    this.handleTextChangedForAutocomplete(oldValue, newValue);
+    this.handleTextChangedForAutocomplete();
     this.autoSaveTrigger$.next();
     this.generateSuggestionTrigger$.next();
   }
 
   // visible for testing
-  handleTextChangedForAutocomplete(oldValue: string, newValue: string) {
-    if (oldValue === newValue) return;
-    // As soon as the user changes the text the hint for autocompletion
-    // is invalidated, *if* what the user typed does not match the
-    // autocompletion!
-    const charsAdded = newValue.length - oldValue.length;
-    if (
-      charsAdded > 0 &&
-      newValue.startsWith(oldValue) &&
-      this.autocompleteHint.startsWith(newValue.substring(oldValue.length))
-    ) {
-      // What the user typed matches the hint, so we keep the hint, but shorten
-      // it accordingly.
-      this.autocompleteHint = this.autocompleteHint.substring(charsAdded);
-      return;
+  handleTextChangedForAutocomplete() {
+    const cachedHint = this.autocompleteCache.get(this.messageText);
+    if (cachedHint) {
+      this.autocompleteHint = cachedHint;
+    } else {
+      this.autocompleteHint = undefined;
+      this.autocompleteTrigger$.next();
     }
-
-    // The default behavior is to reset the hint and to generate a new
-    // autocomplete suggestion.
-    this.autocompleteHint = '';
-    this.autocompleteTrigger$.next();
   }
 
   private renderCommentMessage() {
@@ -1349,6 +1417,7 @@
     const change = this.getChangeModel().getChange();
     if (
       !enabled ||
+      !this.autocompleteEnabled ||
       !suggestionsProvider?.autocompleteComment ||
       !change ||
       !this.comment?.patch_set ||
@@ -1358,6 +1427,7 @@
       return;
     }
     const commentText = this.messageText;
+    this.reporting.time(Timing.COMMENT_COMPLETION);
     const response = await suggestionsProvider.autocompleteComment({
       id: id(this.comment),
       commentText,
@@ -1367,11 +1437,52 @@
       range: this.comment.range,
       lineNumber: this.comment.line,
     });
-    // If between request and response the user has changed the message, then
-    // ignore the suggestion for the old message text.
-    if (this.messageText !== commentText) return;
+    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;
-    this.autocompleteHint = response.completion;
+    // 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() {
@@ -1800,6 +1911,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 7b4f63a..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
@@ -898,27 +898,39 @@
 
   suite('handleTextChangedForAutocomplete', () => {
     test('foo -> foo with asdf', async () => {
-      element.autocompleteHint = 'asdf';
-      element.handleTextChangedForAutocomplete('foo', 'foo');
-      assert.equal(element.autocompleteHint, 'asdf');
+      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 () => {
-      element.autocompleteHint = 'asdf';
-      element.handleTextChangedForAutocomplete('foo', 'bar');
-      assert.equal(element.autocompleteHint, '');
+      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 () => {
-      element.autocompleteHint = 'asdf';
-      element.handleTextChangedForAutocomplete('foo', 'foofoo');
-      assert.equal(element.autocompleteHint, '');
+      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 () => {
-      element.autocompleteHint = 'foomore';
-      element.handleTextChangedForAutocomplete('foo', 'foofoo');
-      assert.equal(element.autocompleteHint, 'more');
+      const ctx = {draftContent: 'foo', commentCompletion: 'foomore'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foofoo';
+      element.handleTextChangedForAutocomplete();
+      assert.equal(element.autocompleteHint.commentCompletion, 'more');
     });
   });
 
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 63c3832..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);
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 c533a5e..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
@@ -152,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);
@@ -221,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() {
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-suggestion-textarea/gr-suggestion-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 2d22f6a..a8a779d 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -348,6 +348,12 @@
           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);
         }
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-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
index 628b65b..9b88e9c 100644
--- a/polygerrit-ui/app/embed/gr-textarea.ts
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -456,13 +456,7 @@
     const value = await this.getValue();
     this.innerValue = value;
 
-    this.dispatchEvent(
-      new CustomEvent('input', {
-        detail: {
-          value: this.value,
-        },
-      })
-    );
+    this.fire('input', {value: this.value});
   }
 
   private onFocus(event: Event) {
@@ -492,7 +486,7 @@
       (event.ctrlKey || event.metaKey)
     ) {
       event.preventDefault();
-      this.dispatchEvent(new CustomEvent('saveShortcut'));
+      this.fire('saveShortcut');
     }
     await this.toggleHintVisibilityIfAny();
   }
@@ -507,7 +501,13 @@
   }
 
   private handleScroll() {
-    this.dispatchEvent(new CustomEvent('scroll'));
+    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) {
@@ -529,14 +529,7 @@
     await this.putCursorAtEnd();
     await this.onInput(event);
 
-    this.dispatchEvent(
-      new CustomEvent('hintApplied', {
-        detail: {
-          hint,
-          oldValue,
-        },
-      })
-    );
+    this.fire('hintApplied', {hint, oldValue});
   }
 
   private async toggleHintVisibilityIfAny() {
@@ -572,6 +565,7 @@
   }
 
   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');
@@ -581,26 +575,16 @@
     );
     hintSpan.dataset['hint'] = hint;
     editableDivElement.appendChild(hintSpan);
-    this.dispatchEvent(
-      new CustomEvent('hintShown', {
-        detail: {
-          hint,
-        },
-      })
-    );
+    this.fire('hintShown', {hint, oldValue});
   }
 
   private removeHintSpanIfShown() {
     const hintSpan = this.hintSpan();
     if (hintSpan) {
       hintSpan.remove();
-      this.dispatchEvent(
-        new CustomEvent('hintDismissed', {
-          detail: {
-            hint: (hintSpan as HTMLElement).dataset['hint'],
-          },
-        })
-      );
+      this.fire('hintDismissed', {
+        hint: (hintSpan as HTMLElement).dataset['hint'],
+      });
     }
   }
 
@@ -616,13 +600,7 @@
     event?.preventDefault();
     event?.stopImmediatePropagation();
 
-    this.dispatchEvent(
-      new CustomEvent('cursorPositionChange', {
-        detail: {
-          position: this.getCursorPosition(),
-        },
-      })
-    );
+    this.fire('cursorPositionChange', {position: this.getCursorPosition()});
   }
 
   private async updateValueInDom() {
diff --git a/polygerrit-ui/app/models/accounts/accounts-model.ts b/polygerrit-ui/app/models/accounts/accounts-model.ts
index 0802f06..6eedcbe8 100644
--- a/polygerrit-ui/app/models/accounts/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts/accounts-model.ts
@@ -42,7 +42,7 @@
   ): Promise<AccountDetailInfo | AccountInfo> {
     const current = this.getState();
     const id = getUserId(partialAccount);
-    if (hasOwnProperty(current.accounts, id)) return current.accounts[id];
+    if (hasOwnProperty(current.accounts, id)) return {...current.accounts[id]};
     // It is possible to add emails to CC when they don't have a Gerrit
     // account. In this case getAccountDetails will return a 404 error then
     // we at least use what is in partialAccount.
diff --git a/polygerrit-ui/app/models/accounts/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
index 53c90a6..e84723c 100644
--- a/polygerrit-ui/app/models/accounts/accounts-model_test.ts
+++ b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
@@ -5,12 +5,23 @@
  */
 
 import '../../test/common-test-setup';
-import {EmailAddress} from '../../api/rest-api';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  Timestamp,
+} from '../../api/rest-api';
 import {getAppContext} from '../../services/app-context';
 import {stubRestApi} from '../../test/test-utils';
 import {AccountsModel} from './accounts-model';
 import {assert} from '@open-wc/testing';
 
+const KERMIT: AccountDetailInfo = {
+  _account_id: 1 as AccountId,
+  name: 'Kermit',
+  registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+};
+
 suite('accounts-model tests', () => {
   let model: AccountsModel;
 
@@ -22,6 +33,24 @@
     model.finalize();
   });
 
+  test('basic lookup', async () => {
+    const stub = stubRestApi('getAccountDetails').returns(
+      Promise.resolve(KERMIT)
+    );
+
+    let filled = await model.fillDetails({_account_id: 1 as AccountId});
+    assert.equal(filled.name, 'Kermit');
+    assert.equal(filled, KERMIT);
+    assert.equal(stub.callCount, 1);
+
+    filled = await model.fillDetails({_account_id: 1 as AccountId});
+    assert.equal(filled.name, 'Kermit');
+    // Cache objects are cloned on lookup, so this is a different object.
+    assert.notEqual(filled, KERMIT);
+    // Did not have to call the REST API again.
+    assert.equal(stub.callCount, 1);
+  });
+
   test('invalid account makes only one request', () => {
     const response = {...new Response(), status: 404};
     const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index ed492c1..497962f 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -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/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/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 0be3ab5..7a12e7a 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
@@ -1565,6 +1565,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);
 
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..814e97a 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,
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..570b50a 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);
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 3829f57..7d666e8 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -712,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..072a872 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1344,6 +1344,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/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 0853941..b93acc6 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -140,10 +140,10 @@
 
 export function isDetailedAccount(account?: AccountInfo) {
   // In case ChangeInfo is requested without DetailedAccount option, the
-  // reviewer entry is returned as just {_account_id: 123}
-  // This object should also be treated as not detailed account if they have
-  // an AccountId and no email
-  return !!account?.email && !!account?._account_id;
+  // reviewer entry is returned as just {_account_id: 123}.
+  // At least a name or an email must be set for the account to be treated as
+  // "detailed".
+  return (!!account?.email || !!account?.name) && !!account?._account_id;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 72fa791..b1ee50e 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -263,6 +263,7 @@
   test('isDetailedAccount', () => {
     assert.isFalse(isDetailedAccount({_account_id: 12345 as AccountId}));
     assert.isFalse(isDetailedAccount({email: 'abcd' as EmailAddress}));
+    assert.isFalse(isDetailedAccount({name: 'Kermit'}));
 
     assert.isTrue(
       isDetailedAccount({
@@ -270,6 +271,12 @@
         email: 'abcd' as EmailAddress,
       })
     );
+    assert.isTrue(
+      isDetailedAccount({
+        _account_id: 12345 as AccountId,
+        name: 'Kermit',
+      })
+    );
   });
 
   test('fails gracefully when all is not included', async () => {
diff --git a/polygerrit-ui/app/utils/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/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/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/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/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/nongoogle.bzl b/tools/nongoogle.bzl
index ac3f668..e185141 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.1"
+
+AUTO_FACTORY_VERSION = "1.0.1"
+
+AUTO_VALUE_VERSION = "1.10.4"
+
 GUAVA_VERSION = "33.0.0-jre"
 
 GUAVA_BIN_SHA1 = "161ba27964a62f241533807a46b8711b13c1d94b"
@@ -182,6 +188,36 @@
     # no concern about version skew.
 
     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 = "error-prone-annotations",
         artifact = "com.google.errorprone:error_prone_annotations:2.22.0",
         sha1 = "bfb9e4281a4cea34f0ec85b3acd47621cfab35b4",
@@ -252,6 +288,12 @@
         sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
     )
 
+    maven_jar(
+        name = "gson",
+        artifact = "com.google.code.gson:gson:2.10.1",
+        sha1 = "b3add478d4382b78ea20b1671390a858002feb6c",
+    )
+
     # Test-only dependencies below.
     maven_jar(
         name = "cglib-3_2",