Merge "ChangeInfo: Add a field with the full branch name"
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 16dab80..e00749d 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -4150,68 +4150,6 @@
   }
 ----
 
-[[migrate-labels]]
-=== Migrate label functions to submit requirements
---
-'POST /projects/link:#project-name[\{project-name\}]/migrate-labels'
---
-
-Migrates labels with functions to submit requirements. The migration result is
-committed into the `refs/meta/config` branch and thus immediately active. As a
-response it returns link:#migrate-labels-info[MigrateLabelsInfo] entity
-describing the outcome of the migration.
-
-The caller must be a project owner.
-
-.Request
-----
-  POST /projects/testproj/migrate-labels HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {"status": "MIGRATED"}
-----
-
-
-[[migrate-labels-change]]
-=== Create change which migrate label functions to submit requirements
---
-'POST /projects/link:#project-name[\{project-name\}]/migrate-labels:review'
---
-
-Creates a change for review which migrates labels with functions to submit requirements.
-As a response it returns link:#migrate-labels-review-info[MigrageLabelsReviewInfo] entity
-describing the outcome of the migration.
-
-.Request
-----
-  POST /projects/testproj/migrate-labels HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "status": "MIGRATED",
-    "change": {
-      "id": "testproj~12345",
-      ...
-    }
-  }
-----
-
-
-
 [[ids]]
 == IDs
 
@@ -5328,40 +5266,6 @@
 a date in the future.
 |=========================
 
-
-[[migrate-labels-info]]
-=== MigrateLabelsInfo
-The `MigrateLabelsInfo` entity contains information about an outcome of labels
-function migration.
-
-[options="header",cols="1,^2,4"]
-|=============================
-|Field Name      ||Description
-|`status`        ||The status of the migration. Takes one of the following values:
-`MIGRATED`,
-`HAS_PROLOG`,
-`PREVIOUSLY_MIGRATED`,
-`NO_CHANGE`
-|=============================
-
-[[migrate-labels-review-info]]
-=== MigrateLabelsReviewInfo
-The `MigrateLabelsReviewInfo` entity contains information about an outcome of creating
-a change for labels function migration.
-
-[options="header",cols="1,^2,4"]
-|=============================
-|Field Name      ||Description
-|`status`        ||The status of the migration. Takes one of the following values:
-`MIGRATED`,
-`HAS_PROLOG`,
-`PREVIOUSLY_MIGRATED`,
-`NO_CHANGE`
-|`change`        |optional|The change created.
-It is a link:rest-api-changes.html#change-info[ChangeInfo] entity
-and is set only when the `status` value is `MIGRATED`.
-|=============================
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/contrib/maintenance/gerrit/site.py b/contrib/maintenance/gerrit/site.py
index 51d567f..faf6c02 100644
--- a/contrib/maintenance/gerrit/site.py
+++ b/contrib/maintenance/gerrit/site.py
@@ -33,7 +33,7 @@
     def get_base_path(self):
         if not self.base_path:
             with GitConfigReader(
-                os.path.join(self.get_etc_path(), "gerrit.config")
+                os.path.join(self.get_etc_path(), "gerrit.config"), []
             ) as cfg:
                 config_base_path = cfg.get("gerrit", None, "basePath", "git")
                 if os.path.isabs(config_base_path):
diff --git a/contrib/maintenance/git/config.py b/contrib/maintenance/git/config.py
index cdcb2b0..0a0f00b 100644
--- a/contrib/maintenance/git/config.py
+++ b/contrib/maintenance/git/config.py
@@ -27,14 +27,16 @@
 
 
 class GitConfigReader:
-    def __init__(self, config_path):
+    def __init__(self, config_path, cmd_options):
         self.path = config_path
+        self.cmd_options = cmd_options
         self.contents = {}
 
     def __enter__(self):
         LOG.debug("reader")
         self.file = open(self.path, "r", encoding="utf-8")
         self._parse()
+        self._parse_cmd_options()
         return self
 
     def __exit__(self, exc_type, exc_val, exc_tb):
@@ -109,6 +111,23 @@
                         ]
         LOG.debug("Parsed config: %s", self.contents)
 
+    def _parse_cmd_options(self):
+        for option in self.cmd_options:
+            key, value = option.split("=", 1)
+            key_parts = key.split(".")
+            if len(key_parts) == 2:
+                section = key_parts[0].lower()
+                subsection = DEFAULT_SUBSECTION
+                key = key_parts[1].lower()
+            elif len(key_parts) == 3:
+                section = key_parts[0].lower()
+                subsection = key_parts[1].lower()
+                key = key_parts[2].lower()
+            else:
+                raise GitConfigException(f"Invalid git config option: {option}")
+            self._ensure_full_section(section, subsection)
+            self.contents[section][subsection][key] = value
+
     def _ensure_full_section(self, section, subsection):
         if section not in self.contents:
             self.contents[section] = {}
@@ -118,7 +137,7 @@
 
 class GitConfigWriter(GitConfigReader):
     def __init__(self, config_path):
-        super().__init__(config_path)
+        super().__init__(config_path, [])
 
     def __enter__(self):
         self.file = open(self.path, "r+", encoding="utf-8")
diff --git a/contrib/maintenance/git/gc.py b/contrib/maintenance/git/gc.py
index fd671e2..546726c 100644
--- a/contrib/maintenance/git/gc.py
+++ b/contrib/maintenance/git/gc.py
@@ -41,6 +41,9 @@
 
 
 class GCStep(abc.ABC):
+    def __init__(self, git_config: GitConfigReader):
+        self.git_config = git_config
+
     @abc.abstractmethod
     def run(self, repo_dir):
         pass
@@ -62,7 +65,9 @@
 
 class PreservePacksInitStep(GCStep):
     def run(self, repo_dir):
-        with GitConfigReader(os.path.join(repo_dir, "config")) as config_reader:
+        with GitConfigReader(
+            os.path.join(repo_dir, "config"), self.git_config
+        ) as config_reader:
             is_prune_preserved = config_reader.get("gc", None, "prunepreserved", False)
             is_preserve_old_packs = config_reader.get(
                 "gc", None, "preserveoldpacks", False
@@ -119,9 +124,6 @@
         return f"{filename}.old-{ext[1:]}"
 
 
-DEFAULT_INIT_STEPS = [GCLockHandlingInitStep(), PreservePacksInitStep()]
-
-
 class DeleteEmptyRefDirsCleanupStep(GCStep):
     def run(self, repo_dir):
         refs_path = os.path.join(repo_dir, "refs")
@@ -180,20 +182,20 @@
             )
 
 
-DEFAULT_AFTER_STEPS = [
-    DeleteEmptyRefDirsCleanupStep(),
-    DeleteStaleIncomingPacksCleanupStep(),
-]
-
-
 class GitGarbageCollectionProvider:
     @staticmethod
     def get(pack_refs=True, git_config=None):
-        init_steps = DEFAULT_INIT_STEPS.copy()
-        after_steps = DEFAULT_AFTER_STEPS.copy()
+        init_steps = [
+            GCLockHandlingInitStep(git_config),
+            PreservePacksInitStep(git_config),
+        ]
+        after_steps = [
+            DeleteEmptyRefDirsCleanupStep(git_config),
+            DeleteStaleIncomingPacksCleanupStep(git_config),
+        ]
 
         if pack_refs:
-            after_steps.append(PackAllRefsAfterStep())
+            after_steps.append(PackAllRefsAfterStep(git_config))
 
         return GitGarbageCollection(init_steps, after_steps, git_config)
 
diff --git a/contrib/maintenance/tests/git/test_config.py b/contrib/maintenance/tests/git/test_config.py
index f8039d6..9f930a2 100644
--- a/contrib/maintenance/tests/git/test_config.py
+++ b/contrib/maintenance/tests/git/test_config.py
@@ -50,12 +50,12 @@
 
 
 def test_list_config(repo_with_config):
-    with GitConfigReader(os.path.join(repo_with_config, "config")) as reader:
+    with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader:
         assert reader.list() == CONFIG_DICT
 
 
 def test_get_config(repo_with_config):
-    with GitConfigReader(os.path.join(repo_with_config, "config")) as reader:
+    with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader:
         assert (
             reader.get("section", None, "key")
             == CONFIG_DICT["section"]["default"]["key"]
@@ -76,13 +76,24 @@
         assert not reader.get("another_section", "default", "another_boolean")
 
 
+def test_get_config_with_override(repo_with_config):
+    with GitConfigReader(
+        os.path.join(repo_with_config, "config"), ["section.key=override"]
+    ) as reader:
+        assert reader.get("section", None, "key") == "override"
+        assert (
+            reader.get("section", "subsection", "another_key")
+            == CONFIG_DICT["section"]["subsection"]["another_key"]
+        )
+
+
 def test_set_config(repo_with_config):
     with GitConfigWriter(os.path.join(repo_with_config, "config")) as writer:
         writer.set("new", None, "key", "value")
         writer.set("new", "new_sub", "key", "val")
         writer.write()
 
-    with GitConfigReader(os.path.join(repo_with_config, "config")) as reader:
+    with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader:
         assert reader.get("new", None, "key") == "value"
         assert reader.get("new", "new_sub", "key") == "val"
 
@@ -94,7 +105,7 @@
         writer.add("section", "subsection", "other_key", "val")
         writer.write()
 
-    with GitConfigReader(os.path.join(repo_with_config, "config")) as reader:
+    with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader:
         assert reader.get("new", None, "key") == "value"
         assert reader.get("section", None, "key") == "value2"
         assert reader.get("section", "subsection", "other_key") == "val"
@@ -106,7 +117,7 @@
         writer.unset("section", "subsection", "another_key")
         writer.write()
 
-    with GitConfigReader(os.path.join(repo_with_config, "config")) as reader:
+    with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader:
         config = reader.list()
         assert DEFAULT_SUBSECTION not in config["section"]
         assert "another_key" not in config["section"]["subsection"]
@@ -117,6 +128,6 @@
         writer.remove("section", "subsection", "other_key", "test")
         writer.write()
 
-    with GitConfigReader(os.path.join(repo_with_config, "config")) as reader:
+    with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader:
         config = reader.list()
         assert "test" not in config["section"]["subsection"]["other_key"]
diff --git a/contrib/maintenance/tests/git/test_gc.py b/contrib/maintenance/tests/git/test_gc.py
index 83c4fda..c8f231c 100644
--- a/contrib/maintenance/tests/git/test_gc.py
+++ b/contrib/maintenance/tests/git/test_gc.py
@@ -42,7 +42,7 @@
     with open(lock_file, "w") as f:
         f.write("1234")
 
-    task = GCLockHandlingInitStep()
+    task = GCLockHandlingInitStep([])
 
     task.run(repo)
     assert os.path.exists(lock_file)
@@ -54,7 +54,7 @@
 
 
 def test_PreservePacksInitStep(repo):
-    task = PreservePacksInitStep()
+    task = PreservePacksInitStep([])
 
     pack_path = os.path.join(repo, "objects", "pack")
     preserved_pack_path = os.path.join(pack_path, "preserved")
@@ -101,6 +101,26 @@
     assert not os.path.exists(fake_preserved_idx)
 
 
+def test_PreservePacksInitStepWithOverride(repo):
+    task = PreservePacksInitStep(["gc.preserveOldPacks=true"])
+
+    pack_path = os.path.join(repo, "objects", "pack")
+    preserved_pack_path = os.path.join(pack_path, "preserved")
+
+    fake_pack = os.path.join(pack_path, "pack-fake.pack")
+    fake_preserved_pack = os.path.join(preserved_pack_path, "pack-fake.old-pack")
+    fake_idx = os.path.join(pack_path, "pack-fake.idx")
+    fake_preserved_idx = os.path.join(preserved_pack_path, "pack-fake.old-idx")
+
+    Path(fake_pack).touch()
+    Path(fake_idx).touch()
+
+    task.run(repo)
+
+    assert os.path.exists(fake_preserved_pack)
+    assert os.path.exists(fake_preserved_idx)
+
+
 def test_DeleteEmptyRefDirsCleanupStep(repo):
     delete_path = os.path.join(repo, "refs", "heads", "delete")
     os.makedirs(delete_path)
@@ -110,7 +130,7 @@
     os.makedirs(keep_path)
     Path(os.path.join(keep_path, "abcd1234")).touch()
 
-    task = DeleteEmptyRefDirsCleanupStep()
+    task = DeleteEmptyRefDirsCleanupStep([])
 
     task.run(repo)
     assert os.path.exists(delete_path)
@@ -138,7 +158,7 @@
     _modify_last_modified(tags_path, DOUBLE_MAX_AGE_EMPTY_REF_DIRS)
     _modify_last_modified(delete_change_path, DOUBLE_MAX_AGE_EMPTY_REF_DIRS)
 
-    task = DeleteEmptyRefDirsCleanupStep()
+    task = DeleteEmptyRefDirsCleanupStep([])
     task.run(repo)
 
     assert not os.path.exists(delete_change_path)
@@ -148,7 +168,7 @@
 
 
 def test_DeleteStaleIncomingPacksCleanupStep(repo):
-    task = DeleteStaleIncomingPacksCleanupStep()
+    task = DeleteStaleIncomingPacksCleanupStep([])
 
     objects_path = os.path.join(repo, "objects")
     pack_path = os.path.join(objects_path, "pack")
@@ -190,7 +210,7 @@
         loose_ref_count += 1
         git.repo.push(local_repo, "origin", f"HEAD:refs/heads/test{loose_ref_count}")
 
-    task = PackAllRefsAfterStep()
+    task = PackAllRefsAfterStep([])
     task.run(repo)
 
     assert len(os.listdir(os.path.join(repo, "refs", "heads"))) == 0
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index d6df3d8..a6e659b 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -139,7 +139,6 @@
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.Revisions;
@@ -269,7 +268,6 @@
   @Inject protected FakeEmailSender sender;
   @Inject protected GerritApi gApi;
   @Inject protected GitRepositoryManager repoManager;
-  @Inject protected RepoMetaDataUpdater repoMetaDataUpdater;
   @Inject protected GroupBackend groupBackend;
   @Inject protected GroupCache groupCache;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index fb2db3a..d23c8b1 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -1026,11 +1026,14 @@
       for (Account.Id id : removable) {
         result.add(accountLoader.get(id));
       }
-      // Reviewers added by email are always removable
+      // Reviewers added by email
       for (Collection<AccountInfo> infos : out.reviewers.values()) {
         for (AccountInfo info : infos) {
           if (info._accountId == null) {
-            result.add(info);
+            if (canRemoveAnyReviewer
+                || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), null, 0)) {
+              result.add(info);
+            }
           }
         }
       }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 0fe526c..b1395b5 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.mail.send.ChangeEmail;
@@ -27,6 +28,8 @@
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
@@ -43,6 +46,7 @@
   private final EmailFactories emailFactories;
   private final MessageIdGenerator messageIdGenerator;
   private final ChangeMessagesUtil changeMessagesUtil;
+  private final RemoveReviewerControl removeReviewerControl;
 
   private final Address reviewer;
   private String mailMessage;
@@ -53,15 +57,18 @@
       EmailFactories emailFactories,
       MessageIdGenerator messageIdGenerator,
       ChangeMessagesUtil changeMessagesUtil,
+      RemoveReviewerControl removeReviewerControl,
       @Assisted Address reviewer) {
     this.emailFactories = emailFactories;
     this.messageIdGenerator = messageIdGenerator;
     this.changeMessagesUtil = changeMessagesUtil;
+    this.removeReviewerControl = removeReviewerControl;
     this.reviewer = reviewer;
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) {
+  public boolean updateChange(ChangeContext ctx) throws PermissionBackendException, AuthException {
+    removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), null);
     change = ctx.getChange();
     PatchSet.Id psId = ctx.getChange().currentPatchSetId();
     ChangeUpdate update = ctx.getUpdate(psId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 343d1a9..4087f84 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -161,7 +161,7 @@
     boolean votesRemoved = false;
     for (PatchSetApproval a : approvals(ctx, reviewerId)) {
       // Check if removing this vote is OK
-      removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+      removeReviewerControl.checkRemoveReviewerApproval(ctx.getNotes(), ctx.getUser(), a);
       if (a.patchSetId().equals(patchSet.id()) && a.value() != 0) {
         oldApprovals.put(a.label(), a.value());
         removedVotesMsg
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index c87caac..ee8cf05 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -52,7 +53,7 @@
    * @throws ResourceConflictException if the approval cannot be removed because the change is
    *     merged
    */
-  public void checkRemoveReviewer(
+  public void checkRemoveReviewerApproval(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
       throws PermissionBackendException, AuthException, ResourceConflictException {
     if (notes.getChange().isMerged() && approval.value() != 0) {
@@ -66,10 +67,15 @@
    * Checks if removing the given reviewer is OK. Does not check if removing any approvals the
    * reviewer might have given is OK.
    *
+   * <p>The `reviewer` parameter is only used for logging purposes and for checking whether the
+   * current user is equal to the reviewer. So it can be set to null. Then the permission check is
+   * generic.
+   *
    * @throws AuthException if this user is not allowed to remove this approval.
    * @throws PermissionBackendException on failure of permission checks.
    */
-  public void checkRemoveReviewer(ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer)
+  public void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, @Nullable Account.Id reviewer)
       throws PermissionBackendException, AuthException {
     checkRemoveReviewer(notes, currentUser, reviewer, 0);
   }
@@ -83,14 +89,14 @@
 
   /** Returns true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
-      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      ChangeNotes notes, CurrentUser currentUser, @Nullable Account.Id reviewer, int value)
       throws PermissionBackendException {
     return testRemoveReviewer(changeDataFactory.create(notes), currentUser, reviewer, value);
   }
 
   /** Returns true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
-      ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
+      ChangeData cd, CurrentUser currentUser, @Nullable Account.Id reviewer, int value)
       throws PermissionBackendException {
     if (cd.change().isMerged() && value != 0) {
       return false;
@@ -110,11 +116,12 @@
   }
 
   private void checkRemoveReviewer(
-      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      ChangeNotes notes, CurrentUser currentUser, @Nullable Account.Id reviewer, int value)
       throws PermissionBackendException, AuthException {
     if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, value)) {
       return;
     }
+    String reviewerStr = reviewer != null ? reviewer.toString() : "by email";
 
     // Users with the remove reviewer permission, the branch owner, project
     // owner and site admin can remove anyone
@@ -125,7 +132,7 @@
           "%s can remove reviewer %s from change %s since they are an owner of the destination"
               + " branch %s",
           currentUser.getLoggableName(),
-          reviewer,
+          reviewerStr,
           notes.getChangeId(),
           notes.getChange().getDest().branch());
       return;
@@ -141,14 +148,14 @@
       logger.atFine().log(
           "%s can remove reviewer %s from change %s since they have the %s permission",
           currentUser.getLoggableName(),
-          reviewer,
+          reviewerStr,
           notes.getChangeId(),
           ChangePermission.REMOVE_REVIEWER.name());
     } catch (AuthException e) {
       logger.atFine().log(
           "%s cannot remove reviewer %s from change %s since they don't have the %s permission",
           currentUser.getLoggableName(),
-          reviewer,
+          reviewerStr,
           notes.getChangeId(),
           ChangePermission.REMOVE_REVIEWER.name());
       throw e;
@@ -156,28 +163,29 @@
   }
 
   public static boolean canRemoveReviewerWithoutPermissionCheck(
-      Change change, CurrentUser currentUser, Account.Id reviewer, int value) {
+      Change change, CurrentUser currentUser, @Nullable Account.Id reviewer, int value) {
+    String reviewerStr = reviewer != null ? reviewer.toString() : "by email";
     if (currentUser.isIdentifiedUser()) {
       Account.Id aId = currentUser.getAccountId();
       if (aId.equals(reviewer)) {
         // A user can always remove themselves.
         logger.atFine().log(
             "%s can remove reviewer %s from change %s since they can always remove themselves",
-            currentUser.getLoggableName(), reviewer, change.getId());
+            currentUser.getLoggableName(), reviewerStr, change.getId());
         return true;
       } else if (aId.equals(change.getOwner()) && 0 <= value) {
         // The change owner may remove any zero or positive score.
         logger.atFine().log(
             "%s can remove reviewer %s from change %s since they own the change and the reviewer"
                 + " scored with zero or a positive score",
-            currentUser.getLoggableName(), reviewer, change.getId());
+            currentUser.getLoggableName(), reviewerStr, change.getId());
         return true;
       }
     }
 
     logger.atFine().log(
         "%s cannot remove reviewer %s from change %s without permission check",
-        currentUser.getLoggableName(), reviewer, change.getId());
+        currentUser.getLoggableName(), reviewerStr, change.getId());
     return false;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 47c6abe..70ba6c5 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -27,7 +27,6 @@
         "//java/com/google/gerrit/server/flow",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
-        "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//lib:args4j",
         "//lib:blame-cache",
diff --git a/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
index 1680b21..6a99bed 100644
--- a/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
@@ -28,7 +28,6 @@
 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.project.RepoMetaDataUpdater;
 import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 96090ed..5fc060a 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -28,8 +28,7 @@
 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.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigChangeCreator;
+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;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 3809929..35102d8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
index 057f493..86d42cb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -30,7 +30,6 @@
 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.project.RepoMetaDataUpdater;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementJson;
 import com.google.gerrit.server.project.SubmitRequirementResource;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 2482a28..b38aa29 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
index ebc5461..64e2399 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabels.java b/java/com/google/gerrit/server/restapi/project/MigrateLabels.java
deleted file mode 100644
index 5fce7f9..0000000
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabels.java
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
-import com.google.gerrit.server.schema.UpdateUI;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Set;
-
-@Singleton
-public class MigrateLabels implements RestModifyView<ProjectResource, MigrateLabelsInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  MigrateLabels(
-      MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement,
-      PermissionBackend permissionBackend) {
-    this.migrateLabelFunctionsToSubmitRequirement = migrateLabelFunctionsToSubmitRequirement;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public Response<MigrateLabelsInfo> apply(ProjectResource rsrc, MigrateLabelsInput input)
-      throws Exception {
-    Project.NameKey project = rsrc.getNameKey();
-    permissionBackend.currentUser().project(project).check(ProjectPermission.WRITE_CONFIG);
-    MigrateLabelFunctionsToSubmitRequirement.Status status =
-        migrateLabelFunctionsToSubmitRequirement.executeMigration(project, new LoggingUpdateUI());
-
-    MigrateLabelsInfo info = new MigrateLabelsInfo();
-    info.status = status;
-    return Response.ok(info);
-  }
-
-  public static class LoggingUpdateUI implements UpdateUI {
-
-    @Override
-    public void message(String message) {
-      logger.atInfo().log("%s", message);
-    }
-
-    @Override
-    public boolean yesno(boolean defaultValue, String message) {
-      return false;
-    }
-
-    @Override
-    public void waitForUser() {}
-
-    @Override
-    public String readString(String defaultValue, Set<String> allowedValues, String message) {
-      return null;
-    }
-
-    @Override
-    public boolean isBatch() {
-      return false;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java
deleted file mode 100644
index b6a2920..0000000
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.server.schema.MigrateLabelFunctionsToSubmitRequirement;
-
-public class MigrateLabelsInfo {
-  public MigrateLabelFunctionsToSubmitRequirement.Status status;
-}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java
deleted file mode 100644
index d010a9d..0000000
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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;
-
-public class MigrateLabelsInput {}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java
deleted file mode 100644
index 0b31d63..0000000
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 static com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement.Status.MIGRATED;
-
-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.RestModifyView;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigChangeCreator;
-import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class MigrateLabelsReview implements RestModifyView<ProjectResource, MigrateLabelsInput> {
-
-  private final RepoMetaDataUpdater repoMetaDataUpdater;
-  private final MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement;
-
-  @Inject
-  MigrateLabelsReview(
-      RepoMetaDataUpdater repoMetaDataUpdater,
-      MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement) {
-    this.repoMetaDataUpdater = repoMetaDataUpdater;
-    this.migrateLabelFunctionsToSubmitRequirement = migrateLabelFunctionsToSubmitRequirement;
-  }
-
-  @Override
-  public Response<MigrateLabelsReviewInfo> apply(ProjectResource rsrc, MigrateLabelsInput input)
-      throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    try (ConfigChangeCreator creator =
-        repoMetaDataUpdater.configChangeCreator(
-            rsrc.getNameKey(), null, MigrateLabelFunctionsToSubmitRequirement.COMMIT_MSG)) {
-      MigrateLabelFunctionsToSubmitRequirement.Status status =
-          migrateLabelFunctionsToSubmitRequirement.updateConfig(
-              rsrc.getProjectState().getNameKey(),
-              creator.getConfig(),
-              new MigrateLabels.LoggingUpdateUI());
-      if (status == MIGRATED) {
-        return Response.ok(new MigrateLabelsReviewInfo(MIGRATED, creator.createChange().value()));
-      }
-      return Response.ok(new MigrateLabelsReviewInfo(status));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java
deleted file mode 100644
index 01d9640..0000000
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.ChangeInfo;
-import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
-
-public class MigrateLabelsReviewInfo {
-  public MigrateLabelFunctionsToSubmitRequirement.Status status;
-  public ChangeInfo change;
-
-  public MigrateLabelsReviewInfo(
-      MigrateLabelFunctionsToSubmitRequirement.Status status, ChangeInfo change) {
-    this.status = status;
-    this.change = change;
-  }
-
-  public MigrateLabelsReviewInfo(MigrateLabelFunctionsToSubmitRequirement.Status status) {
-    this(status, null);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 1900e28..2182784 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
index d0562c6..4e9d432 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -24,8 +24,7 @@
 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.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigChangeCreator;
+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;
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
index 3848d75..71080a5 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -22,7 +22,6 @@
 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.RepoMetaDataUpdater;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.inject.Provider;
 import javax.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
index 4e686fe..f0a371a 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -22,8 +22,7 @@
 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.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigChangeCreator;
+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;
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index adba60e..f5647ec 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -87,9 +87,6 @@
     put(PROJECT_KIND, "config").to(PutConfig.class);
     put(PROJECT_KIND, "config:review").to(PutConfigReview.class);
 
-    post(PROJECT_KIND, "migrate-labels").to(MigrateLabels.class);
-    post(PROJECT_KIND, "migrate-labels:review").to(MigrateLabelsReview.class);
-
     post(PROJECT_KIND, "create.change").to(CreateChange.class);
 
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 39ded77..afd07bb 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -55,8 +55,7 @@
 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.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigUpdater;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
index 3e34227..7e6cc19 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -21,8 +21,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import java.io.IOException;
 import javax.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index 0841d6b..ea685c8 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -26,7 +26,6 @@
 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.project.RepoMetaDataUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
similarity index 98%
rename from java/com/google/gerrit/server/project/RepoMetaDataUpdater.java
rename to java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
index e617f34..a34b7d8 100644
--- a/java/com/google/gerrit/server/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.project;
+package com.google.gerrit.server.restapi.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
@@ -42,6 +42,8 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 286cf90..65851c0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -36,8 +36,7 @@
 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.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigUpdater;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index d5b4a94..a46ee32 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index b894b44..33d4640 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 074e5f8..b94e911 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -47,7 +47,6 @@
 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.project.RepoMetaDataUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
index 3d31e2b3..d3a251e 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -27,7 +27,6 @@
 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.RepoMetaDataUpdater;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementJson;
 import com.google.gerrit.server.project.SubmitRequirementResource;
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index bdc521e..fcd50f6 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -16,30 +16,35 @@
 
 import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.LabelValue;
 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.restapi.MethodNotAllowedException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RepoMetaDataUpdater;
-import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -78,9 +83,8 @@
  */
 public class MigrateLabelFunctionsToSubmitRequirement {
   public static final String COMMIT_MSG = "Migrate label functions to submit requirements";
-
-  private final RepoMetaDataUpdater repoMetaDataUpdater;
   private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
 
   public enum Status {
     /**
@@ -107,9 +111,9 @@
 
   @Inject
   public MigrateLabelFunctionsToSubmitRequirement(
-      RepoMetaDataUpdater repoMetaDataUpdater, GitRepositoryManager repoManager) {
-    this.repoMetaDataUpdater = repoMetaDataUpdater;
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
     this.repoManager = repoManager;
+    this.serverUser = serverUser;
   }
 
   /**
@@ -121,99 +125,176 @@
    * @return {@link Status} reflecting the status of the migration.
    */
   public Status executeMigration(Project.NameKey project, UpdateUI ui)
-      throws IOException,
-          ConfigInvalidException,
-          MethodNotAllowedException,
-          PermissionBackendException {
-    try (ConfigUpdater updater =
-        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(project, null, COMMIT_MSG)) {
-      Status result = updateConfig(project, updater.getConfig(), ui);
-      if (result == Status.MIGRATED) {
-        updater.commitConfigUpdate();
-      }
-      return result;
-    }
-  }
-
-  public Status updateConfig(Project.NameKey project, ProjectConfig projectConfig, UpdateUI ui)
-      throws IOException {
+      throws IOException, ConfigInvalidException {
     boolean updated = false;
     if (hasPrologRules(project)) {
       ui.message(String.format("Skipping project %s because it has prolog rules", project));
       return Status.HAS_PROLOG;
     }
-
-    if (hasMigrationAlreadyRun(project)) {
-      ui.message(
-          String.format(
-              "Skipping migrating label functions to submit requirements for project '%s'"
-                  + " because it has been previously migrated",
-              project));
-      return Status.PREVIOUSLY_MIGRATED;
-    }
-
-    Map<String, LabelType> labelSections = projectConfig.getLabelSections();
-    SubmitRequirementMap existingSubmitRequirements =
-        new SubmitRequirementMap(projectConfig.getSubmitRequirementSections());
-
-    for (Map.Entry<String, LabelType> section : labelSections.entrySet()) {
-      String labelName = section.getKey();
-      LabelType labelType = section.getValue();
-
-      if (labelType.getFunction() == LabelFunction.PATCH_SET_LOCK) {
-        // PATCH_SET_LOCK functions should be left as is
-        continue;
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
+    try (Repository repo = repoManager.openRepository(project);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
+      if (hasMigrationAlreadyRun(repo)) {
+        ui.message(
+            String.format(
+                "Skipping migrating label functions to submit requirements for project '%s'"
+                    + " because it has been previously migrated",
+                project));
+        return Status.PREVIOUSLY_MIGRATED;
       }
-
-      // If the function is other than "NoBlock" we want to reset the label function regardless
-      // of whether there exists a "submit requirement".
-      if (labelType.getFunction() != LabelFunction.NO_BLOCK) {
-        section.setValue(labelType.toBuilder().setNoBlockFunction().build());
-        updated = true;
-      }
-
-      Optional<SubmitRequirement> sr = createSrFromLabelDef(labelType);
-      if (!sr.isPresent()) {
-        continue;
-      }
-      // Make the operation idempotent by skipping creating the submit-requirement if one was
-      // already created or previously existed.
-      if (existingSubmitRequirements.containsKey(labelName)) {
-        SubmitRequirement existing = existingSubmitRequirements.get(labelName);
-        if (!sr.get().equals(existing)) {
-          ui.message(
-              String.format(
-                  "Warning: Skipping creating a submit requirement for label '%s'. An existing "
-                      + "submit requirement is already present but its definition is not "
-                      + "identical to the existing label definition.",
-                  labelName));
+      projectConfig.load(project, repo);
+      Config cfg = projectConfig.getConfig();
+      Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg);
+      Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg);
+      for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) {
+        String labelName = lt.getKey();
+        LabelAttributes attributes = lt.getValue();
+        if (attributes.function().equals("PatchSetLock")) {
+          // PATCH_SET_LOCK functions should be left as is
+          continue;
         }
-        continue;
+        // If the function is other than "NoBlock" we want to reset the label function regardless
+        // of whether there exists a "submit requirement".
+        if (!attributes.function().equals("NoBlock")) {
+          updated = true;
+          writeLabelFunction(cfg, labelName, "NoBlock");
+        }
+        Optional<SubmitRequirement> sr = createSrFromLabelDef(labelName, attributes);
+        if (!sr.isPresent()) {
+          continue;
+        }
+        // Make the operation idempotent by skipping creating the submit-requirement if one was
+        // already created or previously existed.
+        if (existingSubmitRequirements.containsKey(labelName.toLowerCase(Locale.ROOT))) {
+          if (!sr.get()
+              .equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) {
+            ui.message(
+                String.format(
+                    "Warning: Skipping creating a submit requirement for label '%s'. An existing "
+                        + "submit requirement is already present but its definition is not "
+                        + "identical to the existing label definition.",
+                    labelName));
+          }
+          continue;
+        }
+        updated = true;
+        ui.message(
+            String.format(
+                "Project %s: Creating a submit requirement for label %s", project, labelName));
+        writeSubmitRequirement(cfg, sr.get());
       }
-      updated = true;
-      ui.message(
-          String.format(
-              "Project %s: Creating a submit requirement for label %s", project, labelName));
-      existingSubmitRequirements.put(sr.get());
+      if (updated) {
+        commit(projectConfig, md);
+      }
     }
     return updated ? Status.MIGRATED : Status.NO_CHANGE;
   }
 
-  private static Optional<SubmitRequirement> createSrFromLabelDef(LabelType lt) {
-    if (isLabelSkipped(lt)) {
-      return Optional.of(createNonApplicableSr(lt));
-    } else if (isBlockingOrRequiredLabel(lt)) {
-      return Optional.of(createBlockingOrRequiredSr(lt));
+  /**
+   * Returns a Map containing label names as string in keys along with some of its attributes (that
+   * we need in the migration) like canOverride, ignoreSelfApproval and function in the value.
+   */
+  private Map<String, LabelAttributes> getLabelTypes(Config cfg) {
+    Map<String, LabelAttributes> labelTypes = new HashMap<>();
+    for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+      String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+      boolean canOverride =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_CAN_OVERRIDE,
+              /* defaultValue= */ true);
+      boolean ignoreSelfApproval =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_IGNORE_SELF_APPROVAL,
+              /* defaultValue= */ false);
+      ImmutableList<String> values =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
+              .build();
+      ImmutableList<String> refPatterns =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+              .build();
+      LabelAttributes attributes =
+          LabelAttributes.create(
+              function == null ? "MaxWithBlock" : function,
+              canOverride,
+              ignoreSelfApproval,
+              values,
+              refPatterns);
+      labelTypes.put(labelName, attributes);
+    }
+    return labelTypes;
+  }
+
+  private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) {
+    if (sr.description().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_DESCRIPTION,
+          sr.description().get());
+    }
+    if (sr.applicabilityExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+          sr.applicabilityExpression().get().expressionString());
+    }
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        sr.submittabilityExpression().expressionString());
+    if (sr.overrideExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+          sr.overrideExpression().get().expressionString());
+    }
+    cfg.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        sr.allowOverrideInChildProjects());
+  }
+
+  private void writeLabelFunction(Config cfg, String labelName, String function) {
+    cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
+  }
+
+  private void commit(VersionedConfigFile projectConfig, MetaDataUpdate md) throws IOException {
+    md.getCommitBuilder().setAuthor(serverUser);
+    md.getCommitBuilder().setCommitter(serverUser);
+    md.setMessage(COMMIT_MSG);
+    projectConfig.commit(md);
+  }
+
+  private static Optional<SubmitRequirement> createSrFromLabelDef(
+      String labelName, LabelAttributes attributes) {
+    if (isLabelSkipped(attributes.values())) {
+      return Optional.of(createNonApplicableSr(labelName, attributes.canOverride()));
+    } else if (isBlockingOrRequiredLabel(attributes.function())) {
+      return Optional.of(createBlockingOrRequiredSr(labelName, attributes));
     }
     return Optional.empty();
   }
 
-  private static SubmitRequirement createNonApplicableSr(LabelType lt) {
+  private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) {
     return SubmitRequirement.builder()
-        .setName(lt.getName())
+        .setName(labelName)
         .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
         .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
-        .setAllowOverrideInChildProjects(lt.isCanOverride())
+        .setAllowOverrideInChildProjects(canOverride)
         .build();
   }
 
@@ -221,49 +302,51 @@
    * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
    * and/or not voted by the min vote, according to the label attributes.
    */
-  private static SubmitRequirement createBlockingOrRequiredSr(LabelType lt) {
+  private static SubmitRequirement createBlockingOrRequiredSr(
+      String labelName, LabelAttributes attributes) {
     SubmitRequirement.Builder builder =
         SubmitRequirement.builder()
-            .setName(lt.getName())
-            .setAllowOverrideInChildProjects(lt.isCanOverride());
+            .setName(labelName)
+            .setAllowOverrideInChildProjects(attributes.canOverride());
     String maxPart =
-        String.format("label:%s=MAX", lt.getName())
-            + (lt.isIgnoreSelfApproval() ? ",user=non_uploader" : "");
-    switch (lt.getFunction()) {
-      case MAX_WITH_BLOCK ->
+        String.format("label:%s=MAX", labelName)
+            + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : "");
+    switch (attributes.function()) {
+      case "MaxWithBlock" ->
           builder.setSubmittabilityExpression(
               SubmitRequirementExpression.create(
-                  String.format("%s AND -label:%s=MIN", maxPart, lt.getName())));
-      case ANY_WITH_BLOCK ->
+                  String.format("%s AND -label:%s=MIN", maxPart, labelName)));
+      case "AnyWithBlock" ->
           builder.setSubmittabilityExpression(
-              SubmitRequirementExpression.create(String.format("-label:%s=MIN", lt.getName())));
-      case MAX_NO_BLOCK ->
+              SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName)));
+      case "MaxNoBlock" ->
           builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
       default -> {}
     }
-    ImmutableList<String> refPatterns = lt.getRefPatterns();
-    if (refPatterns != null && !refPatterns.isEmpty()) {
+    if (!attributes.refPatterns().isEmpty()) {
       builder.setApplicabilityExpression(
           SubmitRequirementExpression.of(
               String.join(
                   " OR ",
-                  lt.getRefPatterns().stream()
+                  attributes.refPatterns().stream()
                       .map(b -> "branch:\\\"" + b + "\\\"")
                       .collect(Collectors.toList()))));
     }
     return builder.build();
   }
 
-  private static boolean isBlockingOrRequiredLabel(LabelType lt) {
-    return switch (lt.getFunction()) {
-      case ANY_WITH_BLOCK, MAX_WITH_BLOCK, MAX_NO_BLOCK -> true;
-      case NO_BLOCK, NO_OP, PATCH_SET_LOCK -> false;
-    };
+  private static boolean isBlockingOrRequiredLabel(String function) {
+    return function.equals("AnyWithBlock")
+        || function.equals("MaxWithBlock")
+        || function.equals("MaxNoBlock");
   }
 
-  private static boolean isLabelSkipped(LabelType lt) {
-    ImmutableList<LabelValue> values = lt.getValues();
-    return values.isEmpty() || (values.size() == 1 && values.get(0).getValue() == 0);
+  /**
+   * Returns true if the label definition was skipped in the project, i.e. it had only one defined
+   * value: zero.
+   */
+  private static boolean isLabelSkipped(List<String> values) {
+    return values.isEmpty() || (values.size() == 1 && values.get(0).startsWith("0"));
   }
 
   public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException {
@@ -295,53 +378,85 @@
     }
   }
 
-  private boolean hasMigrationAlreadyRun(Project.NameKey project) throws IOException {
-    try (Repository repo = repoManager.openRepository(project)) {
-      try (RevWalk revWalk = new RevWalk(repo)) {
-        Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
-        if (refsMetaConfig == null) {
-          return false;
-        }
-        revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
-        RevCommit commit;
-        while ((commit = revWalk.next()) != null) {
-          if (COMMIT_MSG.equals(commit.getShortMessage())) {
-            return true;
-          }
-        }
+  /**
+   * Returns a map containing submit requirement names in lower name as keys, with {@link
+   * com.google.gerrit.entities.SubmitRequirement} as value.
+   */
+  private Map<String, SubmitRequirement> loadSubmitRequirements(Config rc) {
+    Map<String, SubmitRequirement> allRequirements = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(ProjectConfig.SUBMIT_REQUIREMENT)) {
+      String description =
+          rc.getString(ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_DESCRIPTION);
+      String applicabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String overrideExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION);
+      boolean canInherit =
+          rc.getBoolean(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              false);
+      SubmitRequirement submitRequirement =
+          SubmitRequirement.builder()
+              .setName(name)
+              .setDescription(Optional.ofNullable(description))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+              .setAllowOverrideInChildProjects(canInherit)
+              .build();
+      allRequirements.put(name.toLowerCase(Locale.ROOT), submitRequirement);
+    }
+    return allRequirements;
+  }
+
+  private static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
         return false;
       }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MSG.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
     }
   }
 
-  /**
-   * Helper "Map" to of submit requirements with case-preserving keys and case-insensitive lookup
-   */
-  private static class SubmitRequirementMap {
-    private final Map<String, SubmitRequirement> submitRequirements;
-    private final Map<String, String> lowerCaseToOriginalNames;
+  @AutoValue
+  abstract static class LabelAttributes {
+    abstract String function();
 
-    SubmitRequirementMap(Map<String, SubmitRequirement> submitRequirements) {
-      this.submitRequirements = submitRequirements;
-      this.lowerCaseToOriginalNames =
-          submitRequirements.keySet().stream()
-              .collect(Collectors.toMap(k -> k.toLowerCase(Locale.ROOT), k -> k));
-    }
+    abstract boolean canOverride();
 
-    boolean containsKey(String name) {
-      return lowerCaseToOriginalNames.containsKey(name.toLowerCase(Locale.ROOT));
-    }
+    abstract boolean ignoreSelfApproval();
 
-    @Nullable
-    SubmitRequirement get(String name) {
-      String orig = lowerCaseToOriginalNames.get(name.toLowerCase(Locale.ROOT));
-      return orig != null ? submitRequirements.get(orig) : null;
-    }
+    abstract ImmutableList<String> values();
 
-    void put(SubmitRequirement sr) {
-      String name = sr.name();
-      submitRequirements.put(name, sr);
-      lowerCaseToOriginalNames.put(name.toLowerCase(Locale.ROOT), name);
+    abstract ImmutableList<String> refPatterns();
+
+    static LabelAttributes create(
+        String function,
+        boolean canOverride,
+        boolean ignoreSelfApproval,
+        ImmutableList<String> values,
+        ImmutableList<String> refPatterns) {
+      return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
+          function, canOverride, ignoreSelfApproval, values, refPatterns);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 7e237f5..8e1b86e 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -446,7 +446,7 @@
   private TestUpdateUI runMigration(Status expectedResult) throws Exception {
     TestUpdateUI updateUi = new TestUpdateUI();
     MigrateLabelFunctionsToSubmitRequirement executor =
-        new MigrateLabelFunctionsToSubmitRequirement(repoMetaDataUpdater, repoManager);
+        new MigrateLabelFunctionsToSubmitRequirement(repoManager, serverIdent.get());
     Status status = executor.executeMigration(project, updateUi);
     assertThat(status).isEqualTo(expectedResult);
     projectCache.evictAndReindex(project);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index b38bf9d..51ca30a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -26,6 +27,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
@@ -38,10 +40,12 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import java.lang.reflect.Type;
+import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
 import org.junit.Before;
@@ -422,6 +426,35 @@
     assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).ccs).isEmpty();
   }
 
+  @Test
+  public void removeCcByEmailWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    ReviewerInput input = new ReviewerInput();
+    input.reviewer = "nonexisting@example.com";
+    input.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+    requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(input.reviewer).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  private List<TestAccount> createAccounts(int n, String emailPrefix) throws Exception {
+    List<TestAccount> result = new ArrayList<>(n);
+    for (int i = 0; i < n; i++) {
+      result.add(
+          accountCreator.create(
+              name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i, null));
+    }
+    return result;
+  }
+
   private static String toRfcAddressString(AccountInfo info) {
     return Address.create(info.name, info.email).toString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 6653e1f..792e3b1 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -1327,6 +1327,7 @@
   @Test
   public void deleteReviewerByEmailFromWipChange() throws Exception {
     StagedChange sc = stageWipChangeWithExtraReviewer();
+    requestScopeOperations.setApiUser(sc.owner.id());
     gApi.changes().id(sc.changeId).reviewer(StagedUsers.REVIEWER_BY_EMAIL).remove();
     assertThat(sender).didNotSend();
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 934fd65..37e7ccf 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -16,6 +16,7 @@
 import '../../shared/gr-editable-content/gr-editable-content';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
 import '../gr-change-actions/gr-change-actions';
 import '../gr-change-summary/gr-change-summary';
 import '../gr-change-metadata/gr-change-metadata';
@@ -160,6 +161,7 @@
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
 const PREFIX = '#message-';
+
 @customElement('gr-change-view')
 export class GrChangeView extends LitElement {
   /**
@@ -350,6 +352,9 @@
   @state()
   activeTab: Tab | string = Tab.FILES;
 
+  @state()
+  private showSidebarChat = false;
+
   @property({type: Boolean})
   unresolvedOnly = true;
 
@@ -790,6 +795,13 @@
       sharedStyles,
       modalStyles,
       css`
+        :host {
+          --change-header-height: 38px;
+          --sidebar-top: calc(
+            var(--main-header-height) + var(--change-header-height)
+          );
+          --sidebar-bottom-overflow: var(--main-footer-height);
+        }
         .tabs {
           display: flex;
         }
@@ -806,6 +818,10 @@
           border-bottom: 2px solid var(--border-color);
           display: flex;
           padding: var(--spacing-s) var(--spacing-l);
+          position: sticky;
+          top: var(--main-header-height);
+          height: var(--change-header-height);
+          z-index: 110;
         }
         .header.active {
           border-color: var(--status-active);
@@ -1114,12 +1130,18 @@
 
   private renderMainContent() {
     return html`
-      <div id="mainContent" class="container" ?hidden=${!!this.loading}>
-        ${this.renderChangeInfoSection()}
-        <h2 class="assistive-tech-only">Files and Comments tabs</h2>
-        ${this.renderTabHeaders()} ${this.renderTabContent()}
-        ${this.renderChangeLog()}
-      </div>
+      ${this.renderHeader()}
+      <gr-content-with-sidebar .hideSide=${!this.showSidebarChat}>
+        <div slot="main">
+          <div id="mainContent" class="container" ?hidden=${!!this.loading}>
+            ${this.renderChangeInfoSection()}
+            <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+            ${this.renderTabHeaders()} ${this.renderTabContent()}
+            ${this.renderChangeLog()}
+          </div>
+        </div>
+        <div slot="side">${this.renderSidebar()}</div>
+      </gr-content-with-sidebar>
       <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
@@ -1151,17 +1173,30 @@
     `;
   }
 
-  private renderChangeInfoSection() {
-    return html`<section class="changeInfoSection">
+  private renderHeader() {
+    if (this.loading) return;
+    return html`
       <div class=${this.computeHeaderClass()}>
         <h1 class="assistive-tech-only">
           Change ${this.change?._number}: ${this.change?.subject}
         </h1>
         ${this.renderHeaderTitle()} ${this.renderCommitActions()}
       </div>
-      <h2 class="assistive-tech-only">Change metadata</h2>
-      ${this.renderChangeInfo()}
-    </section>`;
+    `;
+  }
+
+  private renderSidebar() {
+    if (!this.showSidebarChat) return;
+    return html` <div>Hello, I am a fancy sidebar for AI Chat.</div> `;
+  }
+
+  private renderChangeInfoSection() {
+    return html`
+      <section class="changeInfoSection">
+        <h2 class="assistive-tech-only">Change metadata</h2>
+        ${this.renderChangeInfo()}
+      </section>
+    `;
   }
 
   private renderHeaderTitle() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 20bfeec..378d5eb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -293,10 +293,6 @@
       changeNum: TEST_NUMERIC_CHANGE_ID,
       repo: 'gerrit' as RepoName,
     };
-    await element.updateComplete.then(() => {
-      assertIsDefined(element.actions);
-      sinon.stub(element.actions, 'reload').returns(Promise.resolve());
-    });
     userModel = testResolver(userModelToken);
     changeModel = testResolver(changeModelToken);
   });
@@ -310,147 +306,127 @@
       element,
       /* HTML */ `
         <div class="container loading">Loading...</div>
-        <div class="container" hidden="" id="mainContent">
-          <section class="changeInfoSection">
-            <div class="header">
-              <h1 class="assistive-tech-only">Change :</h1>
-              <div class="headerTitle">
-                <div class="changeStatuses"></div>
-                <div class="changeStarContainer">
-                  <gr-button
-                    aria-disabled="false"
-                    class="showCopyLinkDialogButton"
-                    down-arrow=""
-                    flatten=""
-                    id="copyLinkDialogButton"
-                    role="button"
+        <gr-content-with-sidebar>
+          <div slot="main">
+            <div class="container" hidden="" id="mainContent">
+              <section class="changeInfoSection">
+                <h2 class="assistive-tech-only">Change metadata</h2>
+                <div class="changeInfo">
+                  <div class="changeInfo-column changeMetadata">
+                    <gr-change-metadata id="metadata"> </gr-change-metadata>
+                  </div>
+                  <div
+                    class="changeInfo-column mainChangeInfo"
+                    id="mainChangeInfo"
+                  >
+                    <div id="commitAndRelated">
+                      <div class="commitContainer">
+                        <h3 class="assistive-tech-only">Commit Message</h3>
+                        <div>
+                          <gr-button
+                            aria-disabled="false"
+                            class="reply"
+                            id="replyBtn"
+                            primary=""
+                            role="button"
+                            tabindex="0"
+                            title="Open reply dialog to publish comments and add reviewers (shortcut: a)"
+                          >
+                            Reply
+                          </gr-button>
+                        </div>
+                        <div class="commitMessage" id="commitMessage">
+                          <gr-editable-content
+                            id="commitMessageEditor"
+                            remove-zero-width-space=""
+                          >
+                            <gr-formatted-text> </gr-formatted-text>
+                          </gr-editable-content>
+                        </div>
+                        <h3 class="assistive-tech-only">
+                          Comments and Checks Summary
+                        </h3>
+                        <gr-change-summary> </gr-change-summary>
+                        <gr-endpoint-decorator name="commit-container">
+                          <gr-endpoint-param name="change"> </gr-endpoint-param>
+                          <gr-endpoint-param name="revision">
+                          </gr-endpoint-param>
+                        </gr-endpoint-decorator>
+                      </div>
+                      <div class="relatedChanges">
+                        <gr-related-changes-list> </gr-related-changes-list>
+                      </div>
+                      <div class="emptySpace"></div>
+                    </div>
+                  </div>
+                </div>
+              </section>
+              <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+              <div class="tabs">
+                <md-tabs id="tabs">
+                  <md-secondary-tab
+                    active=""
+                    data-name="files"
+                    md-tab=""
                     tabindex="0"
                   >
-                    <gr-change-star id="changeStar"> </gr-change-star>
-                    <a aria-label="Change undefined" class="changeNumber"> </a>
-                  </gr-button>
-                </div>
-                <div class="headerSubject"></div>
-                <gr-copy-clipboard
-                  class="changeCopyClipboard"
-                  hideinput=""
-                  text="undefined: undefined | http://localhost:9876undefined"
-                >
-                </gr-copy-clipboard>
-              </div>
-              <div class="commitActions">
-                <gr-change-actions id="actions"> </gr-change-actions>
-              </div>
-            </div>
-            <h2 class="assistive-tech-only">Change metadata</h2>
-            <div class="changeInfo">
-              <div class="changeInfo-column changeMetadata">
-                <gr-change-metadata id="metadata"> </gr-change-metadata>
-              </div>
-              <div class="changeInfo-column mainChangeInfo" id="mainChangeInfo">
-                <div id="commitAndRelated">
-                  <div class="commitContainer">
-                    <h3 class="assistive-tech-only">Commit Message</h3>
-                    <div>
-                      <gr-button
-                        aria-disabled="false"
-                        class="reply"
-                        id="replyBtn"
-                        primary=""
-                        role="button"
-                        tabindex="0"
-                        title="Open reply dialog to publish comments and add reviewers (shortcut: a)"
-                      >
-                        Reply
-                      </gr-button>
-                    </div>
-                    <div class="commitMessage" id="commitMessage">
-                      <gr-editable-content
-                        id="commitMessageEditor"
-                        remove-zero-width-space=""
-                      >
-                        <gr-formatted-text> </gr-formatted-text>
-                      </gr-editable-content>
-                    </div>
-                    <h3 class="assistive-tech-only">
-                      Comments and Checks Summary
-                    </h3>
-                    <gr-change-summary> </gr-change-summary>
-                    <gr-endpoint-decorator name="commit-container">
+                    <span> Files </span>
+                  </md-secondary-tab>
+                  <md-secondary-tab
+                    class="commentThreads"
+                    data-name="comments"
+                    md-tab=""
+                    tabindex="-1"
+                  >
+                    <gr-tooltip-content has-tooltip="" title="">
+                      <span> Comments </span>
+                    </gr-tooltip-content>
+                  </md-secondary-tab>
+                  <md-secondary-tab
+                    data-name="change-view-tab-header-url"
+                    md-tab=""
+                    tabindex="0"
+                  >
+                    <gr-endpoint-decorator name="change-view-tab-header-url">
                       <gr-endpoint-param name="change"> </gr-endpoint-param>
                       <gr-endpoint-param name="revision"> </gr-endpoint-param>
                     </gr-endpoint-decorator>
-                  </div>
-                  <div class="relatedChanges">
-                    <gr-related-changes-list> </gr-related-changes-list>
-                  </div>
-                  <div class="emptySpace"></div>
-                </div>
+                  </md-secondary-tab>
+                </md-tabs>
               </div>
+              <section class="tabContent">
+                <div>
+                  <gr-file-list-header id="fileListHeader">
+                  </gr-file-list-header>
+                  <gr-revision-parents> </gr-revision-parents>
+                  <gr-file-list id="fileList"> </gr-file-list>
+                </div>
+              </section>
+              <gr-endpoint-decorator name="change-view-integration">
+                <gr-endpoint-param name="change"> </gr-endpoint-param>
+                <gr-endpoint-param name="revision"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+              <div class="tabs">
+                <md-tabs>
+                  <md-secondary-tab
+                    active=""
+                    class="changeLog"
+                    data-name="_changeLog"
+                    md-tab=""
+                    tabindex="0"
+                  >
+                    Change Log
+                  </md-secondary-tab>
+                </md-tabs>
+              </div>
+              <section class="changeLog">
+                <h2 class="assistive-tech-only">Change Log</h2>
+                <gr-messages-list> </gr-messages-list>
+              </section>
             </div>
-          </section>
-          <h2 class="assistive-tech-only">Files and Comments tabs</h2>
-          <div class="tabs">
-            <md-tabs id="tabs">
-              <md-secondary-tab
-                active=""
-                data-name="files"
-                md-tab=""
-                tabindex="0"
-              >
-                <span> Files </span>
-              </md-secondary-tab>
-              <md-secondary-tab
-                class="commentThreads"
-                data-name="comments"
-                md-tab=""
-                tabindex="-1"
-              >
-                <gr-tooltip-content has-tooltip="" title="">
-                  <span> Comments </span>
-                </gr-tooltip-content>
-              </md-secondary-tab>
-              <md-secondary-tab
-                data-name="change-view-tab-header-url"
-                md-tab=""
-                tabindex="0"
-              >
-                <gr-endpoint-decorator name="change-view-tab-header-url">
-                  <gr-endpoint-param name="change"> </gr-endpoint-param>
-                  <gr-endpoint-param name="revision"> </gr-endpoint-param>
-                </gr-endpoint-decorator>
-              </md-secondary-tab>
-            </md-tabs>
           </div>
-          <section class="tabContent">
-            <div>
-              <gr-file-list-header id="fileListHeader"> </gr-file-list-header>
-              <gr-revision-parents> </gr-revision-parents>
-              <gr-file-list id="fileList"> </gr-file-list>
-            </div>
-          </section>
-          <gr-endpoint-decorator name="change-view-integration">
-            <gr-endpoint-param name="change"> </gr-endpoint-param>
-            <gr-endpoint-param name="revision"> </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <div class="tabs">
-            <md-tabs>
-              <md-secondary-tab
-                active=""
-                class="changeLog"
-                data-name="_changeLog"
-                md-tab=""
-                tabindex="0"
-              >
-                Change Log
-              </md-secondary-tab>
-            </md-tabs>
-          </div>
-          <section class="changeLog">
-            <h2 class="assistive-tech-only">Change Log</h2>
-            <gr-messages-list> </gr-messages-list>
-          </section>
-        </div>
+          <div slot="side"></div>
+        </gr-content-with-sidebar>
         <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
         <dialog id="downloadModal" tabindex="-1">
           <gr-download-dialog id="downloadDialog" role="dialog">
@@ -647,7 +623,11 @@
 
   suite('keyboard shortcuts', () => {
     let clock: SinonFakeTimers;
-    setup(() => {
+    setup(async () => {
+      element.change = createChangeViewChange();
+      element.loading = false;
+      await element.updateComplete;
+
       clock = sinon.useFakeTimers();
     });
 
@@ -706,7 +686,9 @@
       assert.isTrue(loggedInErrorSpy.called);
     });
 
-    test('shift A does not open reply overlay', async () => {
+    test('shift A does not open reply overlay, if not logged in', async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
       pressKey(element, 'a', Modifier.SHIFT_KEY);
       await element.updateComplete;
       assertIsDefined(element.replyModal);
@@ -919,7 +901,11 @@
     assert.equal(element.replyBtn.textContent, 'Sign in');
   });
 
-  test('download tap calls handleOpenDownloadDialog', () => {
+  test('download tap calls handleOpenDownloadDialog', async () => {
+    element.change = createChangeViewChange();
+    element.loading = false;
+    await element.updateComplete;
+
     assertIsDefined(element.actions);
     const openDialogStub = sinon.stub(element, 'handleOpenDownloadDialog');
     element.actions.dispatchEvent(
@@ -1126,6 +1112,8 @@
       labels: {},
       actions: {},
     };
+    element.loading = false;
+    await element.updateComplete;
 
     sinon.stub(element, '_getUrlParameter').callsFake(param => {
       assert.equal(param, 'revert');
@@ -1285,6 +1273,7 @@
       const newChange = {...element.change};
       newChange.revisions.rev2 = createRevision(EDIT);
       element.change = newChange;
+      element.loading = false;
       await element.updateComplete;
 
       fireEdit();
@@ -1298,6 +1287,7 @@
       newChange.revisions.rev2 = createRevision(2);
       element.change = newChange;
       element.viewModelPatchNum = 1 as RevisionPatchSetNum;
+      element.loading = false;
       await element.updateComplete;
 
       fireEdit();
@@ -1314,6 +1304,7 @@
       newChange.revisions.rev2 = createRevision(2);
       element.change = newChange;
       element.patchNum = 2 as RevisionPatchSetNum;
+      element.loading = false;
       await element.updateComplete;
 
       fireEdit();
@@ -1329,6 +1320,7 @@
     element.change = {
       ...createChangeViewChange(),
     };
+    element.loading = false;
     await element.updateComplete;
     assertIsDefined(element.metadata);
     assertIsDefined(element.actions);
@@ -1374,6 +1366,7 @@
       starred: false,
     };
     element.loggedIn = true;
+    element.loading = false;
     await element.updateComplete;
 
     const stub = sinon.stub(element, 'handleToggleStar');
@@ -1458,6 +1451,7 @@
       status: ChangeStatus.MERGED,
       current_revision: sha,
     };
+    element.loading = false;
     await element.updateComplete;
 
     const copyLinksDialog = queryAndAssert<GrCopyLinks>(
@@ -1471,6 +1465,7 @@
 
   test('copy links without a base URL', async () => {
     element.change = createChangeViewChange();
+    element.loading = false;
     await element.updateComplete;
 
     const copyLinksDialog = queryAndAssert<GrCopyLinks>(
@@ -1487,6 +1482,7 @@
   test('copy links with a base URL having a path', async () => {
     stubBaseUrl('/review');
     element.change = createChangeViewChange();
+    element.loading = false;
     await element.updateComplete;
 
     const copyLinksDialog = queryAndAssert<GrCopyLinks>(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 80233b05..4b8ce40 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -539,7 +539,7 @@
         .stickyHeader {
           background-color: var(--view-background-color);
           position: sticky;
-          top: 0;
+          top: var(--main-header-height);
           /* sidebar should outrank <footer> in GrAppElement */
           z-index: 110;
           box-shadow: var(--elevation-level-1);
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index acc8cf2..793ed14 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -282,6 +282,8 @@
           display: flex;
           flex-direction: column;
           min-height: 100%;
+          --main-header-height: 48px;
+          --main-footer-height: 36px;
         }
         gr-main-header,
         footer {
@@ -297,17 +299,21 @@
             header should be shown on top of the sticky diff header, which has a
             z-index of 110. */
           z-index: 111;
+          position: sticky;
+          top: 0;
+          height: var(--main-header-height);
         }
         footer {
           background: var(
             --footer-background,
             var(--footer-background-color, #eee)
           );
+          height: var(--main-footer-height);
           border-top: var(--footer-border-top);
           display: flex;
           justify-content: space-between;
           padding: var(--spacing-m) var(--spacing-l);
-          z-index: 100;
+          z-index: 10;
         }
         main {
           flex: 1;
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
new file mode 100644
index 0000000..b61a10a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
@@ -0,0 +1,173 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
+import {styleMap} from 'lit/directives/style-map.js';
+
+const SIDEBAR_MIN_WIDTH = 300;
+
+/**
+ * A component that displays content in a main area and a resizable sidebar.
+ * The sidebar can be toggled between hidden and visible.
+ *
+ * slot main - The content to be displayed in the main area.
+ * slot side - The content to be displayed in the sidebar.
+ */
+@customElement('gr-content-with-sidebar')
+export class GrContentWithSidebar extends LitElement {
+  @query('.sidebar-wrapper') sidebarWrapper?: HTMLElement;
+
+  @state()
+  private sidebarWidthPx = SIDEBAR_MIN_WIDTH;
+
+  @property()
+  hideSide = true;
+
+  private isSidebarResizing = false;
+
+  private sidebarResizingStartPosPx = 0;
+
+  private sidebarResizingStartWidthPx = 0;
+
+  private readonly boundResizeSidebar = (e: MouseEvent) =>
+    this.resizeSidebar(e);
+
+  private readonly boundStopSidebarResize = () => this.stopSidebarResize();
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          --sidebar-height: calc(100vh - var(--sidebar-top));
+          --sidebar-top: var(--sidebar-top, 0px);
+          --sidebar-bottom-overflow: var(--sidebar-bottom-overflow, 0px);
+        }
+        .sidebar-wrapper {
+          z-index: 50;
+          position: absolute;
+          display: flex;
+          top: 0;
+          bottom: calc(0px - var(--sidebar-bottom-overflow));
+          right: 0;
+          min-width: 300px;
+          max-width: 100%;
+          background-color: var(--background-color-secondary);
+        }
+        .sidebar {
+          position: sticky;
+          top: var(--sidebar-top);
+          height: var(--sidebar-height);
+          box-sizing: border-box;
+          overflow: auto;
+          flex-grow: 1;
+          padding: var(--spacing-l);
+          font-size: 14px;
+        }
+        .resizer-wrapper {
+          position: sticky;
+          top: var(--sidebar-top);
+          height: var(--sidebar-height);
+          z-index: 51;
+        }
+        .resizer {
+          background-color: var(--background-color-secondary);
+          width: 7px;
+          border-left: 1px solid var(--border-color);
+          cursor: ew-resize;
+          position: absolute;
+          top: 0;
+          bottom: 0;
+          left: -7px;
+          box-sizing: border-box;
+        }
+        .resizer:hover {
+          background-color: var(--background-color-tertiary);
+          width: 11px;
+          left: -9px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const widthPx = this.hideSide ? 0 : this.sidebarWidthPx;
+    return html`
+      <div>
+        <div style=${styleMap({width: `calc(100% - ${widthPx}px)`})}>
+          <slot name="main"></slot>
+        </div>
+        ${this.renderSidebar()}
+      </div>
+    `;
+  }
+
+  private renderSidebar() {
+    if (this.hideSide) return;
+    return html`
+      <div
+        class="sidebar-wrapper"
+        style=${styleMap({width: `${this.sidebarWidthPx}px`})}
+      >
+        <div class="resizer-wrapper">
+          <div
+            class="resizer"
+            role="separator"
+            aria-orientation="vertical"
+            aria-valuenow=${this.sidebarWidthPx}
+            aria-label="Resize sidebar"
+            tabindex="0"
+            @mousedown=${this.startSidebarResize}
+          ></div>
+        </div>
+        <div class="sidebar">
+          <slot name="side"></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  private startSidebarResize(event: MouseEvent) {
+    if (this.isSidebarResizing) return;
+
+    // Disable user selection while resizing.
+    document.body.style.setProperty('user-select', 'none');
+    this.isSidebarResizing = true;
+    this.sidebarResizingStartPosPx = event.clientX;
+    this.sidebarResizingStartWidthPx =
+      this.sidebarWrapper!.getBoundingClientRect().width;
+    window.addEventListener('mousemove', this.boundResizeSidebar);
+    window.addEventListener('mouseup', this.boundStopSidebarResize);
+  }
+
+  private stopSidebarResize() {
+    if (!this.isSidebarResizing) return;
+
+    // Re-enable user selection when resizing is done.
+    document.body.style.setProperty('user-select', 'auto');
+    this.isSidebarResizing = false;
+    this.sidebarResizingStartPosPx = 0;
+    this.sidebarResizingStartWidthPx = 0;
+    window.removeEventListener('mousemove', this.boundResizeSidebar);
+    window.removeEventListener('mouseup', this.boundStopSidebarResize);
+  }
+
+  private resizeSidebar(event: MouseEvent) {
+    if (!this.isSidebarResizing || event.buttons === 0) return;
+
+    const widthDiffPx = event.clientX - this.sidebarResizingStartPosPx;
+    this.sidebarWidthPx = Math.max(
+      this.sidebarResizingStartWidthPx - widthDiffPx,
+      SIDEBAR_MIN_WIDTH
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-content-with-sidebar': GrContentWithSidebar;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
new file mode 100644
index 0000000..1b09682
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {assert, fixture, html} from '@open-wc/testing';
+import './gr-content-with-sidebar';
+import {GrContentWithSidebar} from './gr-content-with-sidebar';
+
+suite('gr-content-with-sidebar tests', () => {
+  let element: GrContentWithSidebar;
+
+  setup(async () => {
+    element = await fixture<GrContentWithSidebar>(
+      html`<gr-content-with-sidebar></gr-content-with-sidebar>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders no sidebar', async () => {
+    element.hideSide = true;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <div style="width:calc(100% - 0px);">
+            <slot name="main"></slot>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders sidebar', async () => {
+    element.hideSide = false;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <div style="width: calc(100% - 300px);">
+            <slot name="main"> </slot>
+          </div>
+          <div class="sidebar-wrapper" style="width:300px;">
+            <div class="resizer-wrapper">
+              <div
+                aria-label="Resize sidebar"
+                aria-orientation="vertical"
+                aria-valuenow="300"
+                class="resizer"
+                role="separator"
+                tabindex="0"
+              ></div>
+            </div>
+            <div class="sidebar">
+              <slot name="side"> </slot>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
index ec79b5a..e66da3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -53,6 +53,7 @@
         }
         nav.pinned {
           position: fixed;
+          top: var(--main-header-height);
         }
         @media only screen and (max-width: 53em) {
           nav {