Merge "Add REST API for migrating label functions to submit requirements (Reland)"
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 4a0a602..4c60d37 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -4149,6 +4149,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
 
@@ -5265,6 +5327,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/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/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
new file mode 100644
index 0000000..c84b93b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
@@ -0,0 +1,347 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+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.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+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.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A class with logic for migrating existing label functions to submit requirements and resetting
+ * the label functions to {@link LabelFunction#NO_BLOCK}.
+ *
+ * <p>Important note: Callers should do this migration only if this gerrit installation has no
+ * Prolog submit rules (i.e. no rules.pl file in refs/meta/config). Otherwise, the newly created
+ * submit requirements might not behave as intended.
+ *
+ * <p>The conversion is done as follows:
+ *
+ * <ul>
+ *   <li>MaxWithBlock is translated to submittableIf = label:$lbl=MAX AND -label:$lbl=MIN
+ *   <li>MaxNoBlock is translated to submittableIf = label:$lbl=MAX
+ *   <li>AnyWithBlock is translated to submittableIf = -label:$lbl=MIN
+ *   <li>NoBlock/NoOp are translated to applicableIf = is:false (not applicable)
+ *   <li>PatchSetLock labels are left as is
+ * </ul>
+ *
+ * If the label has {@link LabelType#isIgnoreSelfApproval()}, the max vote is appended with the
+ * 'user=non_uploader' argument.
+ *
+ * <p>For labels that were skipped, i.e. had only one "zero" predefined value, the migrator creates
+ * a non-applicable submit-requirement for them. This is done so that if a parent project had a
+ * submit-requirement with the same name, then it's not inherited by this project.
+ *
+ * <p>If there is an existing label and there exists a "submit requirement" with the same name, the
+ * migrator checks if the submit-requirement to be created matches the one in project.config. If
+ * they don't match, a warning message is printed, otherwise nothing happens. In either cases, the
+ * existing submit-requirement is not altered.
+ */
+public class MigrateLabelFunctionsToSubmitRequirement {
+  public static final String COMMIT_MSG = "Migrate label functions to submit requirements";
+
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final GitRepositoryManager repoManager;
+
+  public enum Status {
+    /**
+     * The migrator updated the project config and created new submit requirements and/or did reset
+     * label functions.
+     */
+    MIGRATED,
+
+    /** The project had prolog rules, and the migration was skipped. */
+    HAS_PROLOG,
+
+    /**
+     * The project was migrated with a previous run of this class. The migration for this run was
+     * skipped.
+     */
+    PREVIOUSLY_MIGRATED,
+
+    /**
+     * Migration was run for the project but did not update the project.config because it was
+     * up-to-date.
+     */
+    NO_CHANGE
+  }
+
+  @Inject
+  public MigrateLabelFunctionsToSubmitRequirement(
+      RepoMetaDataUpdater repoMetaDataUpdater, GitRepositoryManager repoManager) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.repoManager = repoManager;
+  }
+
+  /**
+   * For each label function, create a corresponding submit-requirement and set the label function
+   * to NO_BLOCK. Blocking label functions are substituted with blocking submit-requirements.
+   * Non-blocking label functions are substituted with non-applicable submit requirements, allowing
+   * the label vote to be surfaced as a trigger vote (optional label).
+   *
+   * @return {@link Status} reflecting the status of the migration.
+   */
+  public Status executeMigration(Project.NameKey project, UpdateUI ui)
+      throws IOException,
+          ConfigInvalidException,
+          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;
+    }
+
+    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;
+      }
+
+      // 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));
+        }
+        continue;
+      }
+      updated = true;
+      ui.message(
+          String.format(
+              "Project %s: Creating a submit requirement for label %s", project, labelName));
+      existingSubmitRequirements.put(sr.get());
+    }
+    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));
+    }
+    return Optional.empty();
+  }
+
+  private static SubmitRequirement createNonApplicableSr(LabelType lt) {
+    return SubmitRequirement.builder()
+        .setName(lt.getName())
+        .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+        .setAllowOverrideInChildProjects(lt.isCanOverride())
+        .build();
+  }
+
+  /**
+   * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
+   * and/or not voted by the min vote, according to the label attributes.
+   */
+  private static SubmitRequirement createBlockingOrRequiredSr(LabelType lt) {
+    SubmitRequirement.Builder builder =
+        SubmitRequirement.builder()
+            .setName(lt.getName())
+            .setAllowOverrideInChildProjects(lt.isCanOverride());
+    String maxPart =
+        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, lt.getName())));
+      case ANY_WITH_BLOCK ->
+          builder.setSubmittabilityExpression(
+              SubmitRequirementExpression.create(String.format("-label:%s=MIN", lt.getName())));
+      case MAX_NO_BLOCK ->
+          builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
+      default -> {}
+    }
+    ImmutableList<String> refPatterns = lt.getRefPatterns();
+    if (refPatterns != null && !refPatterns.isEmpty()) {
+      builder.setApplicabilityExpression(
+          SubmitRequirementExpression.of(
+              String.join(
+                  " OR ",
+                  lt.getRefPatterns().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 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 {
+    for (Project.NameKey p : allProjects) {
+      if (hasPrologRules(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean hasPrologRules(Project.NameKey project) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader reader = rw.getObjectReader()) {
+      Ref refsConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsConfig == null) {
+        // Project does not have a refs/meta/config and no rules.pl consequently.
+        return false;
+      }
+      RevCommit commit = repo.parseCommit(refsConfig.getObjectId());
+      try (TreeWalk tw = TreeWalk.forPath(reader, RULES_PL_FILE, commit.getTree())) {
+        if (tw != null) {
+          return true;
+        }
+      }
+
+      return false;
+    }
+  }
+
+  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;
+      }
+    }
+  }
+
+  /**
+   * 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;
+
+    SubmitRequirementMap(Map<String, SubmitRequirement> submitRequirements) {
+      this.submitRequirements = submitRequirements;
+      this.lowerCaseToOriginalNames =
+          submitRequirements.keySet().stream()
+              .collect(Collectors.toMap(k -> k.toLowerCase(Locale.ROOT), k -> k));
+    }
+
+    boolean containsKey(String name) {
+      return lowerCaseToOriginalNames.containsKey(name.toLowerCase(Locale.ROOT));
+    }
+
+    @Nullable
+    SubmitRequirement get(String name) {
+      String orig = lowerCaseToOriginalNames.get(name.toLowerCase(Locale.ROOT));
+      return orig != null ? submitRequirements.get(orig) : null;
+    }
+
+    void put(SubmitRequirement sr) {
+      String name = sr.name();
+      submitRequirements.put(name, sr);
+      lowerCaseToOriginalNames.put(name.toLowerCase(Locale.ROOT), name);
+    }
+  }
+}
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..a0aa70a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java
@@ -0,0 +1,60 @@
+// 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.restapi.project.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.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+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..dcf0fd0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java
@@ -0,0 +1,32 @@
+// 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;
+
+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/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);