Merge "doc: Document necessity of using 'http.saveCookies'"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index a12e1ce..e2e3f71 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -8629,7 +8629,9 @@
 === IncludedInInfo
 The `IncludedInInfo` entity contains information about the branches a
 change was merged into and tags it was tagged with. The branch or tag
-must have 'refs/head/' or 'refs/tags/' prefix respectively.
+must have 'refs/heads/' or 'refs/tags/' prefix respectively.
+
+Branch and tag lists are sorted in lexicographical order.
 
 [options="header",cols="1,^1,5"]
 |=======================
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index e00749d..16dab80 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -4150,6 +4150,68 @@
   }
 ----
 
+[[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
 
@@ -5266,6 +5328,40 @@
 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/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index a6e659b..d6df3d8 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -139,6 +139,7 @@
 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;
@@ -268,6 +269,7 @@
   @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/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/project/RepoMetaDataUpdater.java
similarity index 98%
rename from java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
rename to java/com/google/gerrit/server/project/RepoMetaDataUpdater.java
index a34b7d8..e617f34 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/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.restapi.project;
+package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
@@ -42,8 +42,6 @@
 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/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 70ba6c5..47c6abe 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -27,6 +27,7 @@
         "//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 6a99bed..1680b21 100644
--- a/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
@@ -28,6 +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.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 5fc060a..96090ed 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -28,7 +28,8 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.project.RepoMetaDataUpdater;
+import com.google.gerrit.server.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 35102d8..3809929 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -36,6 +36,7 @@
 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 86d42cb..057f493 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -30,6 +30,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.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 b38aa29..2482a28 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -25,6 +25,7 @@
 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 64e2399..ebc5461 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -20,6 +20,7 @@
 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
new file mode 100644
index 0000000..3e889a3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabels.java
@@ -0,0 +1,83 @@
+// 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(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
new file mode 100644
index 0000000..b6a2920
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java
@@ -0,0 +1,21 @@
+// 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
new file mode 100644
index 0000000..d010a9d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java
@@ -0,0 +1,17 @@
+// 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
new file mode 100644
index 0000000..0b31d63
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java
@@ -0,0 +1,62 @@
+// 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
new file mode 100644
index 0000000..01d9640
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java
@@ -0,0 +1,33 @@
+// 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 2182784..1900e28 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -24,6 +24,7 @@
 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 4e9d432..d0562c6 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -24,7 +24,8 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.project.RepoMetaDataUpdater;
+import com.google.gerrit.server.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 71080a5..3848d75 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -22,6 +22,7 @@
 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 f0a371a..4e686fe 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -22,7 +22,8 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.project.RepoMetaDataUpdater;
+import com.google.gerrit.server.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 f5647ec..adba60e 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -87,6 +87,9 @@
     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 afd07bb..39ded77 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -55,7 +55,8 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
+import com.google.gerrit.server.project.RepoMetaDataUpdater;
+import com.google.gerrit.server.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 7e6cc19..3e34227 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -21,7 +21,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.project.RepoMetaDataUpdater;
+import com.google.gerrit.server.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 ea685c8..0841d6b 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -26,6 +26,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.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 65851c0..286cf90 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -36,7 +36,8 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
+import com.google.gerrit.server.project.RepoMetaDataUpdater;
+import com.google.gerrit.server.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 a46ee32..d5b4a94 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -28,6 +28,7 @@
 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 33d4640..b894b44 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -29,6 +29,7 @@
 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 b94e911..074e5f8 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -47,6 +47,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.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 d3a251e..3d31e2b3 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -27,6 +27,7 @@
 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 c6d7dc9..bdc521e 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -16,35 +16,30 @@
 
 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.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.git.meta.VersionedConfigFile;
+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.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 javax.inject.Inject;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -83,8 +78,9 @@
  */
 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 {
     /**
@@ -111,9 +107,9 @@
 
   @Inject
   public MigrateLabelFunctionsToSubmitRequirement(
-      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+      RepoMetaDataUpdater repoMetaDataUpdater, GitRepositoryManager repoManager) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
     this.repoManager = repoManager;
-    this.serverUser = serverUser;
   }
 
   /**
@@ -125,178 +121,99 @@
    * @return {@link Status} reflecting the status of the migration.
    */
   public Status executeMigration(Project.NameKey project, UpdateUI ui)
-      throws IOException, ConfigInvalidException {
+      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 {
+    boolean updated = false;
     if (hasPrologRules(project)) {
       ui.message(String.format("Skipping project %s because it has prolog rules", project));
       return Status.HAS_PROLOG;
     }
-    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
-    boolean migrationPerformed = false;
-    try (Repository repo = repoManager.openRepository(project);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
-      if (hasMigrationAlreadyRun(repo)) {
-        ui.message(
-            String.format(
-                "Skipping migrating label functions to submit requirements for project '%s'"
-                    + " because it has been previously migrated",
-                project));
-        return Status.PREVIOUSLY_MIGRATED;
+
+    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;
       }
-      projectConfig.load(project, repo);
-      Config cfg = projectConfig.getConfig();
-      Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg);
-      Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg);
-      boolean updated = false;
-      for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) {
-        String labelName = lt.getKey();
-        LabelAttributes attributes = lt.getValue();
-        if (attributes.function().equals("PatchSetLock")) {
-          // PATCH_SET_LOCK functions should be left as is
-          continue;
-        }
-        // If the function is other than "NoBlock" we want to reset the label function regardless
-        // of whether there exists a "submit requirement".
-        if (!attributes.function().equals("NoBlock")) {
-          updated = true;
-          writeLabelFunction(cfg, labelName, "NoBlock");
-        }
-        Optional<SubmitRequirement> sr = createSrFromLabelDef(labelName, attributes);
-        if (!sr.isPresent()) {
-          continue;
-        }
-        // Make the operation idempotent by skipping creating the submit-requirement if one was
-        // already created or previously existed.
-        if (existingSubmitRequirements.containsKey(labelName.toLowerCase(Locale.ROOT))) {
-          if (!sr.get()
-              .equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) {
-            ui.message(
-                String.format(
-                    "Warning: Skipping creating a submit requirement for label '%s'. An existing "
-                        + "submit requirement is already present but its definition is not "
-                        + "identical to the existing label definition.",
-                    labelName));
-          }
-          continue;
-        }
+
+      // 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;
-        ui.message(
-            String.format(
-                "Project %s: Creating a submit requirement for label %s", project, labelName));
-        writeSubmitRequirement(cfg, sr.get());
       }
-      if (updated) {
-        commit(projectConfig, md);
-        migrationPerformed = true;
+
+      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));
+        }
+        continue;
+      }
+      updated = true;
+      ui.message(
+          String.format(
+              "Project %s: Creating a submit requirement for label %s", project, labelName));
+      existingSubmitRequirements.put(sr.get());
     }
-    return migrationPerformed ? Status.MIGRATED : Status.NO_CHANGE;
+    return updated ? Status.MIGRATED : Status.NO_CHANGE;
   }
 
-  /**
-   * Returns a Map containing label names as string in keys along with some of its attributes (that
-   * we need in the migration) like canOverride, ignoreSelfApproval and function in the value.
-   */
-  private Map<String, LabelAttributes> getLabelTypes(Config cfg) {
-    Map<String, LabelAttributes> labelTypes = new HashMap<>();
-    for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
-      String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
-      boolean canOverride =
-          cfg.getBoolean(
-              ProjectConfig.LABEL,
-              labelName,
-              ProjectConfig.KEY_CAN_OVERRIDE,
-              /* defaultValue= */ true);
-      boolean ignoreSelfApproval =
-          cfg.getBoolean(
-              ProjectConfig.LABEL,
-              labelName,
-              ProjectConfig.KEY_IGNORE_SELF_APPROVAL,
-              /* defaultValue= */ false);
-      ImmutableList<String> values =
-          ImmutableList.<String>builder()
-              .addAll(
-                  Arrays.asList(
-                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
-              .build();
-      ImmutableList<String> refPatterns =
-          ImmutableList.<String>builder()
-              .addAll(
-                  Arrays.asList(
-                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
-              .build();
-      LabelAttributes attributes =
-          LabelAttributes.create(
-              function == null ? "MaxWithBlock" : function,
-              canOverride,
-              ignoreSelfApproval,
-              values,
-              refPatterns);
-      labelTypes.put(labelName, attributes);
-    }
-    return labelTypes;
-  }
-
-  private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) {
-    if (sr.description().isPresent()) {
-      cfg.setString(
-          ProjectConfig.SUBMIT_REQUIREMENT,
-          sr.name(),
-          ProjectConfig.KEY_SR_DESCRIPTION,
-          sr.description().get());
-    }
-    if (sr.applicabilityExpression().isPresent()) {
-      cfg.setString(
-          ProjectConfig.SUBMIT_REQUIREMENT,
-          sr.name(),
-          ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
-          sr.applicabilityExpression().get().expressionString());
-    }
-    cfg.setString(
-        ProjectConfig.SUBMIT_REQUIREMENT,
-        sr.name(),
-        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-        sr.submittabilityExpression().expressionString());
-    if (sr.overrideExpression().isPresent()) {
-      cfg.setString(
-          ProjectConfig.SUBMIT_REQUIREMENT,
-          sr.name(),
-          ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
-          sr.overrideExpression().get().expressionString());
-    }
-    cfg.setBoolean(
-        ProjectConfig.SUBMIT_REQUIREMENT,
-        sr.name(),
-        ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
-        sr.allowOverrideInChildProjects());
-  }
-
-  private void writeLabelFunction(Config cfg, String labelName, String function) {
-    cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
-  }
-
-  private void commit(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));
+  private static Optional<SubmitRequirement> createSrFromLabelDef(LabelType lt) {
+    if (isLabelSkipped(lt)) {
+      return Optional.of(createNonApplicableSr(lt));
+    } else if (isBlockingOrRequiredLabel(lt)) {
+      return Optional.of(createBlockingOrRequiredSr(lt));
     }
     return Optional.empty();
   }
 
-  private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) {
+  private static SubmitRequirement createNonApplicableSr(LabelType lt) {
     return SubmitRequirement.builder()
-        .setName(labelName)
+        .setName(lt.getName())
         .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
         .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
-        .setAllowOverrideInChildProjects(canOverride)
+        .setAllowOverrideInChildProjects(lt.isCanOverride())
         .build();
   }
 
@@ -304,51 +221,49 @@
    * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
    * and/or not voted by the min vote, according to the label attributes.
    */
-  private static SubmitRequirement createBlockingOrRequiredSr(
-      String labelName, LabelAttributes attributes) {
+  private static SubmitRequirement createBlockingOrRequiredSr(LabelType lt) {
     SubmitRequirement.Builder builder =
         SubmitRequirement.builder()
-            .setName(labelName)
-            .setAllowOverrideInChildProjects(attributes.canOverride());
+            .setName(lt.getName())
+            .setAllowOverrideInChildProjects(lt.isCanOverride());
     String maxPart =
-        String.format("label:%s=MAX", labelName)
-            + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : "");
-    switch (attributes.function()) {
-      case "MaxWithBlock" ->
+        String.format("label:%s=MAX", lt.getName())
+            + (lt.isIgnoreSelfApproval() ? ",user=non_uploader" : "");
+    switch (lt.getFunction()) {
+      case MAX_WITH_BLOCK ->
           builder.setSubmittabilityExpression(
               SubmitRequirementExpression.create(
-                  String.format("%s AND -label:%s=MIN", maxPart, labelName)));
-      case "AnyWithBlock" ->
+                  String.format("%s AND -label:%s=MIN", maxPart, lt.getName())));
+      case ANY_WITH_BLOCK ->
           builder.setSubmittabilityExpression(
-              SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName)));
-      case "MaxNoBlock" ->
+              SubmitRequirementExpression.create(String.format("-label:%s=MIN", lt.getName())));
+      case MAX_NO_BLOCK ->
           builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
       default -> {}
     }
-    if (!attributes.refPatterns().isEmpty()) {
+    ImmutableList<String> refPatterns = lt.getRefPatterns();
+    if (refPatterns != null && !refPatterns.isEmpty()) {
       builder.setApplicabilityExpression(
           SubmitRequirementExpression.of(
               String.join(
                   " OR ",
-                  attributes.refPatterns().stream()
+                  lt.getRefPatterns().stream()
                       .map(b -> "branch:\\\"" + b + "\\\"")
                       .collect(Collectors.toList()))));
     }
     return builder.build();
   }
 
-  private static boolean isBlockingOrRequiredLabel(String function) {
-    return function.equals("AnyWithBlock")
-        || function.equals("MaxWithBlock")
-        || function.equals("MaxNoBlock");
+  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;
+    };
   }
 
-  /**
-   * 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"));
+  private static boolean isLabelSkipped(LabelType lt) {
+    ImmutableList<LabelValue> values = lt.getValues();
+    return values.isEmpty() || (values.size() == 1 && values.get(0).getValue() == 0);
   }
 
   public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException {
@@ -380,85 +295,53 @@
     }
   }
 
-  /**
-   * 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) {
+  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;
+          }
+        }
         return false;
       }
-      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
-      RevCommit commit;
-      while ((commit = revWalk.next()) != null) {
-        if (COMMIT_MSG.equals(commit.getShortMessage())) {
-          return true;
-        }
-      }
-      return false;
     }
   }
 
-  @AutoValue
-  abstract static class LabelAttributes {
-    abstract String function();
+  /**
+   * 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;
 
-    abstract boolean canOverride();
+    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 ignoreSelfApproval();
+    boolean containsKey(String name) {
+      return lowerCaseToOriginalNames.containsKey(name.toLowerCase(Locale.ROOT));
+    }
 
-    abstract ImmutableList<String> values();
+    @Nullable
+    SubmitRequirement get(String name) {
+      String orig = lowerCaseToOriginalNames.get(name.toLowerCase(Locale.ROOT));
+      return orig != null ? submitRequirements.get(orig) : null;
+    }
 
-    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);
+    void put(SubmitRequirement sr) {
+      String name = sr.name();
+      submitRequirements.put(name, sr);
+      lowerCaseToOriginalNames.put(name.toLowerCase(Locale.ROOT), name);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 8e1b86e..7e237f5 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(repoManager, serverIdent.get());
+        new MigrateLabelFunctionsToSubmitRequirement(repoMetaDataUpdater, repoManager);
     Status status = executor.executeMigration(project, updateUi);
     assertThat(status).isEqualTo(expectedResult);
     projectCache.evictAndReindex(project);
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index ac51959..fc7bfba 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -233,6 +233,12 @@
 yarn eslint
 ```
 
+* To run ESLint and apply changes on the whole app:
+
+```sh
+yarn eslintfix
+```
+
 * To run ESLint on just the subdirectory you modified:
 
 ```sh
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index ed85fe2..7695b60 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -33,6 +33,7 @@
   // Fires GerritView values such as 'change', 'dashboard', 'admin', ...
   VIEW_CHANGE = 'view-change',
   SHOW_REVISION_ACTIONS = 'show-revision-actions',
+  BEFORE_COMMIT_MSG_EDIT = 'before-commit-msg-edit',
   COMMIT_MSG_EDIT = 'commitmsgedit',
   CUSTOM_EMOJIS = 'custom-emojis',
   REVERT = 'revert',
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 29fdb92..9b06622 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -360,7 +360,7 @@
         <label class="selectionLabel">
           <md-checkbox
             ?checked=${this.checked}
-            @click=${this.toggleCheckbox}
+            @change=${this.toggleCheckbox}
           ></md-checkbox>
         </label>
       </td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index bd53670..d8c1a5d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -296,7 +296,7 @@
           class="selection-checkbox"
           ?checked=${checked}
           .indeterminate=${indeterminate}
-          @click=${this.handleSelectAllCheckboxClicked}
+          @change=${this.handleSelectAllCheckboxClicked}
         ></md-checkbox>
       </td>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 216559c..0393242 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -54,6 +54,7 @@
   RequestPayload,
   RevertSubmissionInfo,
   ReviewInput,
+  RevisionInfo,
 } from '../../../types/common';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -1361,7 +1362,10 @@
   }
 
   // private but used in test
-  getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) {
+  getRevision(
+    change: ChangeInfo,
+    patchNum?: PatchSetNumber
+  ): RevisionInfo | null {
     for (const rev of Object.values(change.revisions ?? {})) {
       if (rev._number === patchNum) {
         return rev;
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 942a77f..934fd65 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
@@ -1658,7 +1658,7 @@
   }
 
   // Private but used in tests.
-  handleCommitMessageSave(e: EditableContentSaveEvent) {
+  async handleCommitMessageSave(e: EditableContentSaveEvent) {
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.changeNum, 'changeNum');
     // to prevent 2 requests at the same time
@@ -1667,6 +1667,19 @@
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
     const committerEmail = e.detail.committerEmail;
 
+    // The BEFORE event handlers are async and can potentially block
+    // the message edit from going through.  By contrast, the second
+    // set of event handlers always fire (and should probably fire
+    // after the message has been saved successfully, but the current
+    // behavior is what it is).
+    if (
+      !(await this.getPluginLoader().jsApiService.handleBeforeCommitMessage(
+        this.change,
+        message
+      ))
+    )
+      return;
+
     this.getPluginLoader().jsApiService.handleCommitMessage(
       this.change,
       message
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 825fa3e..0409f80 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
@@ -1044,15 +1044,17 @@
       });
 
     assertIsDefined(element.commitMessageEditor);
-    element.handleCommitMessageSave(
+    await element.handleCommitMessageSave(
       mockEvent('test \n  test ', committerEmail)
     );
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
     element.commitMessageEditor.disabled = false;
-    element.handleCommitMessageSave(mockEvent('  test\ntest', committerEmail));
+    await element.handleCommitMessageSave(
+      mockEvent('  test\ntest', committerEmail)
+    );
     assert.equal(putStub.lastCall.args[1], '  test\ntest');
     element.commitMessageEditor.disabled = false;
-    element.handleCommitMessageSave(
+    await element.handleCommitMessageSave(
       mockEvent('\n\n\n\n\n\n\n\n', committerEmail)
     );
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 623fe40..04c8618 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -1425,6 +1425,7 @@
       );
       if (!this.ccs.find(cc => getUserId(cc) === getUserId(accountToMove))) {
         this.ccs = [...this.ccs, accountToMove];
+        this.reviewersMutated = true;
       }
     } else {
       this.ccs = this.ccs.filter(
@@ -1434,6 +1435,7 @@
         !this.reviewers.find(r => getUserId(r) === getUserId(accountToMove))
       ) {
         this.reviewers = [...this.reviewers, accountToMove];
+        this.reviewersMutated = true;
       }
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts b/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts
index de37852..6fdb74e 100644
--- a/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts
+++ b/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts
@@ -67,7 +67,7 @@
           class="selectionLabel"
           id=${option.name}
           ?checked=${!!this.isOptionSelected.get(option.name)}
-          @click=${() => this.toggleCheckbox(option)}
+          @change=${() => this.toggleCheckbox(option)}
         ></md-checkbox>
         <label for=${option.name}
           >${capitalizeFirstLetter(option.description)}</label
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 6471d1e..f348a80 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -10,9 +10,12 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-default-editor/gr-default-editor';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {
   Base64FileContent,
+  ChangeInfo,
   EditPreferencesInfo,
+  RevisionInfo,
   RevisionPatchSetNum,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -94,6 +97,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -541,11 +546,27 @@
             return;
           }
           assertIsDefined(this.change, 'change');
+
+          this.getPluginLoader().jsApiService.handlePublishEdit(
+            this.change as ChangeInfo,
+            this.getLatestRevision(this.change as ChangeInfo)
+          );
+
           this.getChangeModel().navigateToChangeResetReload();
         });
     });
   };
 
+  private getLatestRevision(change: ChangeInfo): RevisionInfo | null {
+    const patchNum = this.latestPatchsetNumber;
+    for (const rev of Object.values(change.revisions ?? {})) {
+      if (rev._number === patchNum) {
+        return rev;
+      }
+    }
+    return null;
+  }
+
   private handleContentChange(e: CustomEvent<{value: string}>) {
     this.storeTask = debounce(
       this.storeTask,
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 9e970e6..8fb6e88 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -82,7 +82,7 @@
                 id="numberCheckbox"
                 name="number"
                 ?checked=${!!this.showNumber}
-                @click=${this.handleNumberCheckboxClick}
+                @change=${this.handleNumberCheckboxClick}
               ></md-checkbox>
             </td>
           </tr>
@@ -100,7 +100,7 @@
           id=${column}
           name=${column}
           ?checked=${!this.computeIsColumnHidden(column)}
-          @click=${this.handleTargetClick}
+          @change=${this.handleTargetClick}
         ></md-checkbox>
       </td>
     </tr>`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index b322d46..a0a747d 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -160,7 +160,7 @@
     assert.equal(element.displayedColumns.length, displayedLength - 1);
   });
 
-  test('show item', async () => {
+  test('show and hide item', async () => {
     element.displayedColumns = [
       ColumnNames.STATUS,
       ColumnNames.OWNER,
@@ -171,20 +171,35 @@
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
     await element.updateComplete;
-    const checkbox = queryAndAssert<MdCheckbox>(
+
+    const checkboxSubject = queryAndAssert<MdCheckbox>(
       element,
       'table tr:nth-child(2) md-checkbox'
     );
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isFalse(isChecked);
-    const table = queryAndAssert<HTMLTableElement>(element, 'table');
-    assert.equal(table.style.display, '');
+    assert.equal(checkboxSubject.name, 'Subject');
+    const checkboxOwner = queryAndAssert<MdCheckbox>(
+      element,
+      'table tr:nth-child(3) md-checkbox'
+    );
+    assert.equal(checkboxOwner.name, 'Owner');
 
-    checkbox.click();
+    assert.equal(element.displayedColumns.length, 5);
+    assert.isFalse(checkboxSubject.checked);
+    assert.isTrue(checkboxOwner.checked);
+
+    checkboxSubject.click();
     await element.updateComplete;
 
-    assert.equal(element.displayedColumns.length, displayedLength + 1);
+    assert.equal(element.displayedColumns.length, 6);
+    assert.isTrue(checkboxSubject.checked);
+    assert.isTrue(checkboxOwner.checked);
+
+    checkboxOwner.click();
+    await element.updateComplete;
+
+    assert.equal(element.displayedColumns.length, 5);
+    assert.isTrue(checkboxSubject.checked);
+    assert.isFalse(checkboxOwner.checked);
   });
 
   test('getDisplayedColumns', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 56f5f55..df26cb8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -880,7 +880,7 @@
           <md-checkbox
             class="show-hide"
             ?checked=${!!this.collapsed}
-            @click=${() => (this.collapsed = !this.collapsed)}
+            @change=${() => (this.collapsed = !this.collapsed)}
           ></md-checkbox>
           <gr-icon icon=${icon} id="icon"></gr-icon>
         </label>
@@ -1031,7 +1031,7 @@
               <md-checkbox
                 id="resolvedCheckbox"
                 ?checked=${!this.unresolved}
-                @click=${this.handleToggleResolved}
+                @change=${this.handleToggleResolved}
               ></md-checkbox>
               Resolved
             </label>
@@ -1182,7 +1182,7 @@
           <md-checkbox
             id="generateSuggestCheckbox"
             ?checked=${this.generateSuggestion}
-            @click=${() => {
+            @change=${() => {
               this.generateSuggestion = !this.generateSuggestion;
               // Reset so suggestion can be re-generated.
               this.wasSuggestionEdited = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 1e02b29..7c8bcf4 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -366,7 +366,7 @@
           <md-checkbox
             class="show-hide"
             ?checked=${this.collapsed}
-            @click=${() => {
+            @change=${() => {
               this.collapsed = !this.collapsed;
               if (this.collapsed) {
                 this.reporting.reportInteraction(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 6d8496d..8417a94 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -195,6 +195,23 @@
     }
   }
 
+  async handleBeforeCommitMessage(
+    change: ChangeInfo | ParsedChangeInfo,
+    msg: string
+  ): Promise<boolean> {
+    let okay = true;
+    for (const cb of this._getEventCallbacks(
+      EventType.BEFORE_COMMIT_MSG_EDIT
+    )) {
+      try {
+        okay = (await cb(change, msg)) && okay;
+      } catch (err: unknown) {
+        this.reportError(err, EventType.BEFORE_COMMIT_MSG_EDIT);
+      }
+    }
+    return okay;
+  }
+
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
     for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
       try {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index a000456..9f40f7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -70,6 +70,17 @@
   ): string;
   addElement(key: TargetElement, el: HTMLElement): void;
   getAdminMenuLinks(): MenuLink[];
+  /**
+   * This method is called before handling a commit message edit.
+   * It allows plugins to conditionally block edits.
+   * @param change The relevant change.
+   * @param msg The new commit message text.
+   * @return A promise that resolves to true if the action should proceed.
+   */
+  handleBeforeCommitMessage(
+    change: ChangeInfo | ParsedChangeInfo,
+    msg: string
+  ): Promise<boolean>;
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
   getReviewPostRevert(change?: ChangeInfo): ReviewInput;
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 5577075..a71d225 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -21,7 +21,14 @@
   RevisionPatchSetNum,
 } from '../../types/common';
 import {ChangeStatus, DefaultBase} from '../../constants/constants';
-import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
+import {
+  BehaviorSubject,
+  combineLatest,
+  forkJoin,
+  from,
+  Observable,
+  of,
+} from 'rxjs';
 import {
   catchError,
   filter,
@@ -52,6 +59,7 @@
 import {Model} from '../base/model';
 import {UserModel} from '../user/user-model';
 import {define} from '../dependency';
+import {FlagsService, KnownExperimentId} from '../../services/flags/flags';
 import {
   isOwner,
   isUploader,
@@ -351,6 +359,10 @@
 
   private latestPatchNum?: PatchSetNumber;
 
+  private readonly reloadSubmittabilityTrigger$ = new BehaviorSubject<void>(
+    undefined
+  );
+
   public readonly change$ = select(
     this.state$,
     changeState => changeState.change
@@ -520,7 +532,8 @@
     private readonly restApiService: RestApiService,
     private readonly userModel: UserModel,
     private readonly pluginLoader: PluginLoader,
-    private readonly reporting: ReportingService
+    private readonly reporting: ReportingService,
+    private readonly flagsService: FlagsService
   ) {
     super(initialState);
     this.patchNum$ = select(
@@ -798,14 +811,22 @@
       .subscribe(mergeable => this.updateState({mergeable}));
   }
 
+  public reloadSubmittability() {
+    this.reloadSubmittabilityTrigger$.next();
+  }
+
   private loadSubmittabilityInfo() {
     // Use the same trigger as loadChange, to run SR loading in parallel.
-    return this.viewModel.changeNum$
+    return combineLatest([
+      this.viewModel.changeNum$,
+      this.reloadSubmittabilityTrigger$,
+    ])
       .pipe(
+        map(([changeNum, _]) => changeNum),
         switchMap(changeNum => {
           if (!changeNum) {
-            // On reload changeNum is set to undefined to reset change state.
-            // We propagate undefined and reset the state in this case.
+            // On change reload changeNum is set to undefined to reset change
+            // state. We propagate undefined and reset the state in this case.
             return of(undefined);
           }
           return from(this.restApiService.getSubmittabilityInfo(changeNum));
@@ -814,7 +835,11 @@
       .subscribe(submittabilityInfo => {
         // TODO(b/445644919): Remove once the submit_requirements is never
         // requested as part of the change detail.
-        if (this.change?.submit_requirements) {
+        if (
+          !this.flagsService.isEnabled(
+            KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+          )
+        ) {
           return;
         }
         const change = fillFromSubmittabilityInfo(
@@ -1102,19 +1127,11 @@
     change = updateRevisionsWithCommitShas(change);
     // TODO(b/445644919): Remove once the submit_requirements is never requested
     // as part of the change detail.
-    if (change!.submit_requirements) {
-      this.updateState({
-        change,
-        submittabilityInfo: {
-          changeNum: change!._number,
-          submittable: change!.submittable!,
-          submitRequirements: change!.submit_requirements,
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      return;
+    if (
+      this.flagsService.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS)
+    ) {
+      change = fillFromSubmittabilityInfo(change, this.submittabilityInfo);
     }
-    change = fillFromSubmittabilityInfo(change, this.submittabilityInfo);
     this.updateState({
       change,
       loadingStatus: LoadingStatus.LOADED,
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index a58a070..ce3533e 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -62,6 +62,7 @@
 import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {ShowChangeDetail} from '../../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {SubmittabilityInfo} from '../../services/gr-rest-api/gr-rest-api';
+import {FlagsService, KnownExperimentId} from '../../services/flags/flags';
 
 suite('updateRevisionsWithCommitShas() tests', () => {
   test('undefined edit', async () => {
@@ -221,11 +222,26 @@
   });
 });
 
+class TestFlagService implements FlagsService {
+  public experiments: Set<string> = new Set();
+
+  finalize() {}
+
+  isEnabled(experimentId: string): boolean {
+    return this.experiments.has(experimentId);
+  }
+
+  get enabledExperiments() {
+    return [...this.experiments];
+  }
+}
+
 suite('change model tests', () => {
   let changeViewModel: ChangeViewModel;
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
   let knownChangeNoRevision: ChangeInfo;
+  let testFlagService: TestFlagService;
   const testCompleted = new Subject<void>();
 
   async function waitForLoadingStatus(
@@ -239,6 +255,7 @@
   }
 
   setup(() => {
+    testFlagService = new TestFlagService();
     changeViewModel = testResolver(changeViewModelToken);
     changeModel = new ChangeModel(
       testResolver(navigationToken),
@@ -246,7 +263,8 @@
       getAppContext().restApiService,
       testResolver(userModelToken),
       testResolver(pluginLoaderToken),
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testFlagService
     );
     knownChangeNoRevision = {
       ...createChange(),
@@ -490,6 +508,9 @@
   });
 
   test('load submit requirements (SRs load first)', async () => {
+    testFlagService.experiments.add(
+      KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+    );
     const promiseDetail = mockPromise<ParsedChangeInfo | undefined>();
     const stubDetail = stubRestApi('getChangeDetail').callsFake(
       () => promiseDetail
@@ -520,6 +541,9 @@
   });
 
   test('load submit requirements (Detail load first, experiment enabled)', async () => {
+    testFlagService.experiments.add(
+      KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+    );
     const promiseDetail = mockPromise<ParsedChangeInfo | undefined>();
     const stubDetail = stubRestApi('getChangeDetail').callsFake(
       () => promiseDetail
@@ -550,7 +574,7 @@
     assert.equal(stubSrs.callCount, 1);
   });
 
-  test('load submit requirements (Detail load first, experiment disabled)', async () => {
+  test('load submit requirements (experiment disabled)', async () => {
     const promiseDetail = mockPromise<ParsedChangeInfo | undefined>();
     const stubDetail = stubRestApi('getChangeDetail').callsFake(
       () => promiseDetail
@@ -559,25 +583,54 @@
     const stubSrs = stubRestApi('getSubmittabilityInfo').callsFake(
       () => promiseSrs
     );
-    let state: ChangeState;
     testResolver(changeViewModelToken).setState(createChangeViewState());
+    promiseSrs.resolve(undefined);
     promiseDetail.resolve({
       ...knownChange,
       submittable: false,
       submit_requirements: [createSubmitRequirementResultInfo()],
     });
-    state = await waitForLoadingStatus(LoadingStatus.LOADED);
-    promiseSrs.resolve(undefined);
-    state = await waitUntilObserved(
+    const state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    // Validate that submit requirements didn't get reset to undefined.
+    assert.isTrue(state.change?.submittable === false);
+    assert.isTrue(state.change?.submit_requirements?.length === 1);
+    assert.equal(stubDetail.callCount, 1);
+    assert.equal(stubSrs.callCount, 1);
+  });
+
+  test('reload submit requirements', async () => {
+    testFlagService.experiments.add(
+      KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+    );
+    // Set initial state
+    const stubDetail = stubRestApi('getChangeDetail').resolves(knownChange);
+    const stubSrs = stubRestApi('getSubmittabilityInfo');
+    stubSrs.resolves({
+      changeNum: knownChange._number,
+      submittable: false,
+      submitRequirements: [createSubmitRequirementResultInfo()],
+    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    await waitUntilObserved(
       changeModel.state$,
       state => state.submittabilityInfo !== undefined,
       'SubmitRequirements was never loaded'
     );
-    // Validate that submit requirements didn't get reset to undefined.
-    assert.isTrue(state.submittabilityInfo?.submittable === false);
-    assert.isTrue(state.submittabilityInfo?.submitRequirements.length === 1);
+    await waitForLoadingStatus(LoadingStatus.LOADED);
+    stubSrs.resolves({
+      changeNum: knownChange._number,
+      submittable: true,
+      submitRequirements: [createSubmitRequirementResultInfo()],
+    });
+    changeModel.reloadSubmittability();
+    const state = await waitUntilObserved(
+      changeModel.state$,
+      state => state.submittabilityInfo?.submittable === true,
+      'Submittability never reloaded'
+    );
+    assert.isTrue(state.change?.submittable);
     assert.equal(stubDetail.callCount, 1);
-    assert.equal(stubSrs.callCount, 1);
+    assert.equal(stubSrs.callCount, 2);
   });
 
   test('navigating to another change', async () => {
diff --git a/polygerrit-ui/app/models/flows/flows-model.ts b/polygerrit-ui/app/models/flows/flows-model.ts
index 70f0661..460cec6 100644
--- a/polygerrit-ui/app/models/flows/flows-model.ts
+++ b/polygerrit-ui/app/models/flows/flows-model.ts
@@ -12,6 +12,7 @@
 
 import {NumericChangeId} from '../../types/common';
 import {getAppContext} from '../../services/app-context';
+import {KnownExperimentId} from '../../services/flags/flags';
 
 export interface FlowsState {
   flows: FlowInfo[];
@@ -32,6 +33,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private flagService = getAppContext().flagsService;
+
   constructor(private readonly changeModel: ChangeModel) {
     super({
       flows: [],
@@ -48,7 +51,11 @@
       combineLatest([this.changeModel.changeNum$, this.reload$])
         .pipe(
           switchMap(([changeNum]) => {
-            if (!changeNum) return of([]);
+            if (
+              !changeNum ||
+              !this.flagService.isEnabled(KnownExperimentId.SHOW_FLOWS_TAB)
+            )
+              return of([]);
             this.setState({...this.getState(), loading: true});
             return from(this.restApiService.listFlows(changeNum)).pipe(
               catchError(err => {
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 24d37a8..a0ab3a3 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -161,7 +161,8 @@
           appContext.restApiService,
           resolver(userModelToken),
           resolver(pluginLoaderToken),
-          appContext.reportingService
+          appContext.reportingService,
+          appContext.flagsService
         ),
     ],
     [