Merge "Add rest API endpoints for batch update submit requirements."
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 8d30728..b9fa35a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3917,6 +3917,103 @@
   HTTP/1.1 204 No Content
 ----
 
+[[batch-update-submit-requirements]]
+=== Batch Update Submit Requirements
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements/'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-submit-requirement-input[BatchSubmitRequirementInput] entity.
+
+The updates are processed in the following order:
+
+1. submit requirements deletions
+2. submit requirements creations
+3. submit requirements updates
+
+.Request
+----
+  POST /projects/My-Project/submit_requirements/ HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Submit Requirements",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ]
+  }
+----
+
+If the submit requirements updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[create-submit-requirements-change]]
+=== Create Submit Requirements Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements:review'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+This takes the same input as link:#batch-update-submit-requirements[Batch Update Submit Requirements],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+  POST /projects/testproj/submit_requirements:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Submit Requirements",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
 [[ids]]
 == IDs
 
@@ -4661,6 +4758,27 @@
 entities that describe the updates that should be done for the labels.
 |=============================
 
+[[batch-submit-requirement-input]]
+=== BatchSubmitRequirementInput
+The `BatchSubmitRequirementInput` entity contains information for batch updating submit requirements
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the submit requirements updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete`        |optional|
+List of submit requirements that should be deleted.
+|`create`        |optional|
+List of link:#submit-requirement-input[SubmitRequirementInput] entities that
+describe submit requirements that should be created.
+|`update`        |optional|
+Map of label names to link:#submit-requirement-input[SubmitRequirementInput]
+entities that describe the updates that should be done for the submit requirements.
+|=============================
+
 [[project-access-input]]
 === ProjectAccessInput
 The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index cd805d0..7dc070c 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -1587,6 +1588,18 @@
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
 
+  /** Creates a submit requirement with all required field. */
+  protected void configSubmitRequirement(Project.NameKey project, String name) throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName(name)
+            .setAllowOverrideInChildProjects(true)
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+            .build());
+  }
+
   protected void configSubmitRequirement(
       Project.NameKey project, SubmitRequirement submitRequirement) throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index fa78fdb..58fd93a 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ListTagSortOption;
@@ -292,6 +293,21 @@
   ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException;
 
   /**
+   * Adds, updates and deletes submit requirements definitions in a batch.
+   *
+   * @param input input that describes additions, updates and deletions of submit requirements
+   */
+  void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException;
+
+  /**
+   * Creates a change with required submit requirements updates.
+   *
+   * <p>See {@link #submitRequirements(BatchSubmitRequirementInput)} for details
+   */
+  @CanIgnoreReturnValue
+  ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -506,5 +522,16 @@
     public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
new file mode 100644
index 0000000..8a5f30d
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+/**
+ * Input for the REST API that describes additions, updates and deletions of submit requirements.
+ */
+public class BatchSubmitRequirementInput extends AbstractBatchInput<SubmitRequirementInput> {}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index c817b93..66914b7 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -87,6 +88,8 @@
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.PostLabelsReview;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirements;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirementsReview;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
 import com.google.gerrit.server.restapi.project.PutConfigReview;
@@ -151,6 +154,9 @@
   private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
   private final PostLabelsReview postLabelsReview;
+
+  private final PostSubmitRequirements postSubmitRequirements;
+  private final PostSubmitRequirementsReview postSubmitRequirementsReview;
   private final LabelApiImpl.Factory labelApi;
   private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
@@ -196,6 +202,8 @@
       PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -239,6 +247,8 @@
         postLabelsReview,
         labelApi,
         submitRequirementApi,
+        postSubmitRequirements,
+        postSubmitRequirementsReview,
         null);
   }
 
@@ -284,6 +294,8 @@
       PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -327,6 +339,8 @@
         postLabelsReview,
         labelApi,
         submitRequirementApi,
+        postSubmitRequirements,
+        postSubmitRequirementsReview,
         name);
   }
 
@@ -372,6 +386,8 @@
       PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -415,6 +431,8 @@
     this.postLabelsReview = postLabelsReview;
     this.labelApi = labelApi;
     this.submitRequirementApi = submitRequirementApi;
+    this.postSubmitRequirements = postSubmitRequirements;
+    this.postSubmitRequirementsReview = postSubmitRequirementsReview;
   }
 
   @Override
@@ -848,4 +866,24 @@
       throw asRestApiException("Cannot create change for labels update", e);
     }
   }
+
+  @Override
+  public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+    try {
+      @SuppressWarnings("unused")
+      var unused = postSubmitRequirements.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirements", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+      throws RestApiException {
+    try {
+      return postSubmitRequirementsReview.apply(checkExists(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change for submit requirements update", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
new file mode 100644
index 0000000..3a9fb92
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Provider;
+import jakarta.inject.Singleton;
+import javax.inject.Inject;
+
+@Singleton
+public class PostSubmitRequirements
+    extends AbstractPostCollection<
+        IdString, SubmitRequirementResource, SubmitRequirementInput, BatchSubmitRequirementInput> {
+  CreateSubmitRequirement createSubmitRequirement;
+  DeleteSubmitRequirement deleteSubmitRequirement;
+  UpdateSubmitRequirement updateSubmitRequirement;
+
+  @Inject
+  public PostSubmitRequirements(
+      RepoMetaDataUpdater updater,
+      Provider<CurrentUser> user,
+      CreateSubmitRequirement createSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement) {
+    super(updater, user);
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+  }
+
+  @Override
+  public String defaultCommitMessage() {
+    return "Update Submit Requirements";
+  }
+
+  @Override
+  protected boolean updateItem(ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, UnprocessableEntityException {
+    // The name and input.name can be different - the item should be renamed.
+    if (config.getSubmitRequirementSections().remove(name) == null) {
+      throw new UnprocessableEntityException(
+          String.format("Submit requirement %s not found", name));
+    }
+    var unused = updateSubmitRequirement.updateSubmitRequirement(config, input.name, input);
+    return true;
+  }
+
+  @Override
+  protected void createItem(ProjectConfig config, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    var unused = createSubmitRequirement.createSubmitRequirement(config, input.name, input);
+  }
+
+  @Override
+  protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+    if (!deleteSubmitRequirement.deleteSubmitRequirement(config, name)) {
+      throw new UnprocessableEntityException(
+          String.format("Submit requirement %s not found", name));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
new file mode 100644
index 0000000..82761e7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostSubmitRequirementsReview
+    implements RestModifyView<ProjectResource, BatchSubmitRequirementInput> {
+
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PostSubmitRequirements postSubmitRequirements;
+
+  @Inject
+  PostSubmitRequirementsReview(
+      RepoMetaDataUpdater repoMetaDataUpdater, PostSubmitRequirements postSubmitRequirements) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.postSubmitRequirements = postSubmitRequirements;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, BatchSubmitRequirementInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review submit requirements change")) {
+      ProjectConfig config = creator.getConfig();
+      var unused = postSubmitRequirements.updateProjectConfig(config, input);
+      // If config isn't updated, the createChange throws BadRequestException. We don't need
+      // to explicitly check the updateProjectConfig result here.
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index 07e1e72..5c8bf3d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -117,6 +117,8 @@
     put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
     get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
     delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+    postOnCollection(SUBMIT_REQUIREMENT_KIND).to(PostSubmitRequirements.class);
+    post(PROJECT_KIND, "submit_requirements:review").to(PostSubmitRequirementsReview.class);
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     create(TAG_KIND).to(CreateTag.class);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
new file mode 100644
index 0000000..f9759ac
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class SubmitRequirementsReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void createSubmitRequirementsChangeWithDefaultMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo("Review submit requirements change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("submit-requirement", "Foo", "description"))
+        .isEqualTo("SR description");
+    assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+        .isEqualTo("topic:foo");
+    assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+        .isEqualTo("label:code-review=+2");
+  }
+
+  @Test
+  public void createSubmitRequirementsChangeWithCustomMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+    String customMessage = "test custom message";
+    input.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("submit-requirement", "Foo", "description"))
+        .isEqualTo("SR description");
+    assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+        .isEqualTo("topic:foo");
+    assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+        .isEqualTo("label:code-review=+2");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index 0cda3ba..18c435f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -90,6 +90,8 @@
           RestCall.get("/projects/%s/statistics.git"),
           RestCall.get("/projects/%s/submit_requirements"),
           RestCall.put("/projects/%s/submit_requirements/new-sr"),
+          RestCall.post("/projects/%s/submit_requirements/"),
+          RestCall.post("/projects/%s/submit_requirements:review"),
           RestCall.get("/projects/%s/tags"),
           RestCall.put("/projects/%s/tags/new-tag"),
           RestCall.post("/projects/%s/tags:delete"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
new file mode 100644
index 0000000..adc0a8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
@@ -0,0 +1,386 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostSubmitRequirementsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .submitRequirements(new BatchSubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .submitRequirements(new BatchSubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void deleteNonExistingSR() throws Exception {
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void deleteSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo", "Bar");
+    gApi.projects().name(project.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+  }
+
+  @Test
+  public void deleteSR_namesAreTrimmed() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of(" Foo ", " Bar ");
+    gApi.projects().name(project.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteTheSameSRTwice() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo", "Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void cannotCreateSRWithNameThatIsAlreadyInUse() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    SubmitRequirementInput srInput = new SubmitRequirementInput();
+    srInput.name = "Foo";
+    srInput.allowOverrideInChildProjects = false;
+    srInput.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(srInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void cannotCreateTwoSRWithTheSameName() throws Exception {
+    SubmitRequirementInput srInput = new SubmitRequirementInput();
+    srInput.name = "Foo";
+    srInput.allowOverrideInChildProjects = false;
+    srInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(srInput, srInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void cannotCreateTwoSrWithConflictingNames() throws Exception {
+    SubmitRequirementInput sr1Input = new SubmitRequirementInput();
+    sr1Input.name = "Foo";
+    sr1Input.allowOverrideInChildProjects = false;
+    sr1Input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInput sr2Input = new SubmitRequirementInput();
+    sr2Input.name = "foo";
+    sr2Input.allowOverrideInChildProjects = false;
+    sr2Input.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(sr1Input, sr2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void createSubmitRequirements() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInput barInput = new SubmitRequirementInput();
+    barInput.name = "Bar";
+    barInput.allowOverrideInChildProjects = false;
+    barInput.submittabilityExpression = "label:code-review=+1";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void cannotCreateSRWithIncorrectName() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo ";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooInput, fooInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Name can only consist of alphanumeric characters");
+  }
+
+  @Test
+  public void cannotCreateSRWithoutName() throws Exception {
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(new SubmitRequirementInput());
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Empty submit requirement name");
+  }
+
+  @Test
+  public void updateNonExistingSR() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo2";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.update = ImmutableMap.of("Foo", fooInput);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void updateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    SubmitRequirementInput barUpdate = new SubmitRequirementInput();
+    barUpdate.name = "Baz";
+    barUpdate.submittabilityExpression = "label:code-review=+1";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+    gApi.projects().name(project.get()).submitRequirements(input);
+
+    assertThat(gApi.projects().name(project.get()).submitRequirement("Foo").get().description)
+        .isEqualTo(fooUpdate.description);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .submitRequirement("Foo")
+                .get()
+                .submittabilityExpression)
+        .isEqualTo(fooUpdate.submittabilityExpression);
+    assertThat(gApi.projects().name(project.get()).submitRequirement("Baz").get()).isNotNull();
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .submitRequirement("Baz")
+                .get()
+                .submittabilityExpression)
+        .isEqualTo(barUpdate.submittabilityExpression);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).submitRequirement("Bar").get());
+  }
+
+  @Test
+  public void deleteAndRecreateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooUpdate);
+
+    gApi.projects().name(project.get()).submitRequirements(input);
+
+    SubmitRequirementInfo fooSR =
+        gApi.projects().name(project.get()).submitRequirement("Foo").get();
+    assertThat(fooSR.description).isEqualTo(fooUpdate.description);
+    assertThat(fooSR.submittabilityExpression).isEqualTo(fooUpdate.submittabilityExpression);
+  }
+
+  @Test
+  public void cannotDeleteAndUpdateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    input.update = ImmutableMap.of("Foo", fooUpdate);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    gApi.projects().name(allProjects.get()).submitRequirements(new BatchSubmitRequirementInput());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update Submit Requirements");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.commitMessage = "Batch Update SubmitRequirements";
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.commitMessage = "Batch Update SubmitRequirements ";
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Batch Update SubmitRequirements");
+  }
+}