Add endpoints to create, update and get a submit requirement
We create two new endpoints:
* PUT /projects/{project-name}/submit_requirements/{name}
* GET /projects/{project-name}/submit_requirements/{name}
The endpoints can be used to create a new SR, update an SR or retrieve
the definition of an SR given its name.
The logic in SubmitRequirementExpressionsValidator is refactored; this
was previously used to validate SRs for changes in project.config, and
it now also used in the 'Create SR' and 'Update SR' rest endpoints.
The 'update EP' is exactly the same as the 'create EP', except that
it expects the SR to be present on the server.
Change-Id: Ib97740e63211c1066bb21fccaa196513a8de3134
Release-Notes: Add change rest endpoints to create and retrieve submit requirements.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 6fa584ac..20795b6 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3393,6 +3393,133 @@
HTTP/1.1 200 OK
----
+[[submit-requirement-endpoints]]
+== Submit Requirement Endpoints
+
+[[create-submit-requirement]]
+=== Create Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Creates a new submit requirement definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a submit requirement with this name is already defined in this project, this
+submit requirement definition is updated
+(see link:#update-submit-requirement[Update Submit Requirement]).
+
+The submit requirement data must be provided in the request body as
+link:#submit-requirement-input[SubmitRequirementInput].
+
+.Request
+----
+ PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "name": "Code-Review",
+ "description": "At least one maximum vote for the Code-Review label is required",
+ "submittability_expression": "label:Code-Review=+2"
+ }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "name": "Code-Review",
+ "description": "At least one maximum vote for the Code-Review label is required",
+ "submittability_expression": "label:Code-Review=+2",
+ "allow_override_in_child_projects": false
+ }
+----
+
+[[update-submit-requirement]]
+=== Update Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Updates the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The new submit requirement will overwrite the existing submit requirement.
+That is, unspecified attributes will be set to their defaults.
+
+.Request
+----
+ PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "name": "Code-Review",
+ "description": "At least one maximum vote for the Code-Review label is required",
+ "submittability_expression": "label:Code-Review=+2"
+ }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "name": "Code-Review",
+ "description": "At least one maximum vote for the Code-Review label is required",
+ "submittability_expression": "label:Code-Review=+2",
+ "allow_override_in_child_projects": false
+ }
+----
+
+[[get-submit-requirement]]
+=== Get Submit Requirement
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Retrieves the definition of a submit requirement that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+ GET /projects/All-Projects/submit-requirement/Code-Review HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "name": "Code-Review",
+ "description": "At least one maximum vote for the Code-Review label is required",
+ "submittability_expression": "label:Code-Review=+2",
+ "allow_override_in_child_projects": false
+ }
+----
[[ids]]
== IDs
@@ -3421,6 +3548,11 @@
=== \{label-name\}
The name of a review label.
+[[submit-requirement-name]]
+=== \{submit-requirement-name\}
+The name of a submit requirement.
+
+
[[project-name]]
=== \{project-name\}
The name of the project.
@@ -4366,6 +4498,57 @@
|`size_of_packed_objects` |Size of packed objects in bytes.
|======================================
+[[submit-requirement-info]]
+=== SubmitRequirementInfo
+The `SubmitRequirementInfo` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`||
+Whether this submit requirement can be overridden in child projects.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`false`.
+|===========================
+
[[submit-type-info]]
=== SubmitTypeInfo
Information about the link:config-project-config.html#submit-type[default submit
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 59475a4..587c2c7 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -227,6 +227,8 @@
LabelApi label(String labelName) throws RestApiException;
+ SubmitRequirementApi submitRequirement(String name) throws RestApiException;
+
/**
* Adds, updates and deletes label definitions in a batch.
*
@@ -426,6 +428,11 @@
}
@Override
+ public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public void labels(BatchLabelInput input) throws RestApiException {
throw new NotImplementedException();
}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5baed86..f1620cc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -41,6 +41,7 @@
import com.google.gerrit.extensions.api.projects.ParentInput;
import com.google.gerrit.extensions.api.projects.ProjectApi;
import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
import com.google.gerrit.extensions.api.projects.TagApi;
import com.google.gerrit.extensions.api.projects.TagInfo;
import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -140,6 +141,7 @@
private final Provider<ListLabels> listLabels;
private final PostLabels postLabels;
private final LabelApiImpl.Factory labelApi;
+ private final SubmitRequirementApiImpl.Factory submitRequirementApi;
@AssistedInject
ProjectApiImpl(
@@ -179,6 +181,7 @@
Provider<ListLabels> listLabels,
PostLabels postLabels,
LabelApiImpl.Factory labelApi,
+ SubmitRequirementApiImpl.Factory submitRequirementApi,
@Assisted ProjectResource project) {
this(
permissionBackend,
@@ -218,6 +221,7 @@
listLabels,
postLabels,
labelApi,
+ submitRequirementApi,
null);
}
@@ -259,6 +263,7 @@
Provider<ListLabels> listLabels,
PostLabels postLabels,
LabelApiImpl.Factory labelApi,
+ SubmitRequirementApiImpl.Factory submitRequirementApi,
@Assisted String name) {
this(
permissionBackend,
@@ -298,6 +303,7 @@
listLabels,
postLabels,
labelApi,
+ submitRequirementApi,
name);
}
@@ -339,6 +345,7 @@
Provider<ListLabels> listLabels,
PostLabels postLabels,
LabelApiImpl.Factory labelApi,
+ SubmitRequirementApiImpl.Factory submitRequirementApi,
String name) {
this.permissionBackend = permissionBackend;
this.createProject = createProject;
@@ -378,6 +385,7 @@
this.listLabels = listLabels;
this.postLabels = postLabels;
this.labelApi = labelApi;
+ this.submitRequirementApi = submitRequirementApi;
}
@Override
@@ -746,6 +754,15 @@
}
@Override
+ public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+ try {
+ return submitRequirementApi.create(checkExists(), name);
+ } catch (Exception e) {
+ throw asRestApiException("Cannot parse submit requirement", e);
+ }
+ }
+
+ @Override
public void labels(BatchLabelInput input) throws RestApiException {
try {
postLabels.apply(checkExists(), input);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsModule.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 987c71f..9f7e1b4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsModule.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -29,5 +29,6 @@
factory(CommitApiImpl.Factory.class);
factory(DashboardApiImpl.Factory.class);
factory(LabelApiImpl.Factory.class);
+ factory(SubmitRequirementApiImpl.Factory.class);
}
}
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
new file mode 100644
index 0000000..96bdc82
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -0,0 +1,113 @@
+// 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.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.restapi.project.CreateSubmitRequirement;
+import com.google.gerrit.server.restapi.project.GetSubmitRequirement;
+import com.google.gerrit.server.restapi.project.SubmitRequirementsCollection;
+import com.google.gerrit.server.restapi.project.UpdateSubmitRequirement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SubmitRequirementApiImpl implements SubmitRequirementApi {
+ interface Factory {
+ SubmitRequirementApiImpl create(ProjectResource project, String name);
+ }
+
+ private final SubmitRequirementsCollection submitRequirements;
+ private final CreateSubmitRequirement createSubmitRequirement;
+ private final UpdateSubmitRequirement updateSubmitRequirement;
+ private final GetSubmitRequirement getSubmitRequirement;
+ private final String name;
+ private final ProjectCache projectCache;
+
+ private ProjectResource project;
+
+ @Inject
+ SubmitRequirementApiImpl(
+ SubmitRequirementsCollection submitRequirements,
+ CreateSubmitRequirement createSubmitRequirement,
+ UpdateSubmitRequirement updateSubmitRequirement,
+ GetSubmitRequirement getSubmitRequirement,
+ ProjectCache projectCache,
+ @Assisted ProjectResource project,
+ @Assisted String name) {
+ this.submitRequirements = submitRequirements;
+ this.createSubmitRequirement = createSubmitRequirement;
+ this.updateSubmitRequirement = updateSubmitRequirement;
+ this.getSubmitRequirement = getSubmitRequirement;
+ this.projectCache = projectCache;
+ this.project = project;
+ this.name = name;
+ }
+
+ @Override
+ public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+ try {
+ createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+
+ // recreate project resource because project state was updated
+ project =
+ new ProjectResource(
+ projectCache
+ .get(project.getNameKey())
+ .orElseThrow(illegalState(project.getNameKey())),
+ project.getUser());
+
+ return this;
+ } catch (Exception e) {
+ throw asRestApiException("Cannot create submit requirement", e);
+ }
+ }
+
+ @Override
+ public SubmitRequirementInfo get() throws RestApiException {
+ try {
+ return getSubmitRequirement.apply(resource()).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot get submit requirement", e);
+ }
+ }
+
+ @Override
+ public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+ try {
+ return updateSubmitRequirement.apply(resource(), input).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot update submit requirement", e);
+ }
+ }
+
+ @Override
+ public void delete() throws RestApiException {
+ /** TODO(ghareeb): implement */
+ }
+
+ private SubmitRequirementResource resource() throws RestApiException, PermissionBackendException {
+ return submitRequirements.parse(project, IdString.fromDecoded(name));
+ }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index a750d8e..3c080d2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -188,7 +188,7 @@
import com.google.gerrit.server.project.ProjectCacheImpl;
import com.google.gerrit.server.project.ProjectNameLockManager;
import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
import com.google.gerrit.server.query.FileEditsPredicate;
@@ -397,7 +397,7 @@
DynamicSet.setOf(binder(), UserScopedEventListener.class);
DynamicSet.setOf(binder(), CommitValidationListener.class);
DynamicSet.bind(binder(), CommitValidationListener.class)
- .to(SubmitRequirementExpressionsValidator.class);
+ .to(SubmitRequirementConfigValidator.class);
DynamicSet.setOf(binder(), CommentValidator.class);
DynamicSet.setOf(binder(), ChangeMessageModifier.class);
DynamicSet.setOf(binder(), RefOperationValidationListener.class);
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index d816d84..7ace1c8 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -550,6 +550,7 @@
return submitRequirementSections;
}
+ /** Adds or replaces the given {@link SubmitRequirement} in this config. */
public void upsertSubmitRequirement(SubmitRequirement requirement) {
submitRequirementSections.put(requirement.name(), requirement);
}
@@ -1018,7 +1019,7 @@
continue;
}
- // The expressions are validated in SubmitRequirementExpressionsValidator.
+ // The expressions are validated in SubmitRequirementConfigValidator.
SubmitRequirement submitRequirement =
SubmitRequirement.builder()
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
new file mode 100644
index 0000000..faca446
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
@@ -0,0 +1,135 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementConfigValidator implements CommitValidationListener {
+ private final DiffOperations diffOperations;
+ private final ProjectConfig.Factory projectConfigFactory;
+ private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+ @Inject
+ SubmitRequirementConfigValidator(
+ DiffOperations diffOperations,
+ ProjectConfig.Factory projectConfigFactory,
+ SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+ this.diffOperations = diffOperations;
+ this.projectConfigFactory = projectConfigFactory;
+ this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+ }
+
+ @Override
+ public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+ throws CommitValidationException {
+ try {
+ if (!event.refName.equals(RefNames.REFS_CONFIG)
+ || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+ // the project.config file in refs/meta/config was not modified, hence we do not need to
+ // validate the submit requirements in it
+ return ImmutableList.of();
+ }
+
+ ProjectConfig projectConfig = getProjectConfig(event);
+ ImmutableList.Builder<String> validationMsgsBuilder = ImmutableList.builder();
+ for (SubmitRequirement submitRequirement :
+ projectConfig.getSubmitRequirementSections().values()) {
+ validationMsgsBuilder.addAll(
+ submitRequirementExpressionsValidator.validateExpressions(submitRequirement));
+ }
+ ImmutableList<String> validationMsgs = validationMsgsBuilder.build();
+ if (!validationMsgs.isEmpty()) {
+ throw new CommitValidationException(
+ String.format(
+ "invalid submit requirement expressions in %s (revision = %s)",
+ ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
+ new ImmutableList.Builder<CommitValidationMessage>()
+ .add(
+ new CommitValidationMessage(
+ "Invalid project configuration", ValidationMessage.Type.ERROR))
+ .addAll(
+ validationMsgs.stream()
+ .map(m -> toCommitValidationMessage(m))
+ .collect(Collectors.toList()))
+ .build());
+ }
+ return ImmutableList.of();
+ } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+ throw new CommitValidationException(
+ String.format(
+ "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+ + " of project %s",
+ ProjectConfig.PROJECT_CONFIG,
+ event.commit.getName(),
+ RefNames.REFS_CONFIG,
+ event.project.getNameKey()),
+ e);
+ }
+ }
+
+ private static CommitValidationMessage toCommitValidationMessage(String message) {
+ return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+ }
+
+ /**
+ * Whether the given file was changed in the given revision.
+ *
+ * @param receiveEvent the receive event
+ * @param fileName the name of the file
+ */
+ private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+ throws DiffNotAvailableException {
+ return diffOperations
+ .listModifiedFilesAgainstParent(
+ receiveEvent.project.getNameKey(),
+ receiveEvent.commit,
+ /* parentNum=*/ 0,
+ DiffOptions.DEFAULTS)
+ .keySet().stream()
+ .anyMatch(fileName::equals);
+ }
+
+ private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+ throws IOException, ConfigInvalidException {
+ ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+ projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+ return projectConfig;
+ }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
index 8717581..f2e4ff8 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -15,144 +15,59 @@
package com.google.gerrit.server.project;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
import com.google.inject.Inject;
-import java.io.IOException;
+import com.google.inject.Singleton;
import java.util.ArrayList;
-import java.util.Collection;
import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-/**
- * Validates the expressions of submit requirements in {@code project.config}.
- *
- * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
- * ProjectConfig#loadSubmitRequirementSections(Config)}.
- *
- * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
- * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
- * {@link ProjectConfig} is cached in the project cache).
- */
-public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
- private final DiffOperations diffOperations;
- private final ProjectConfig.Factory projectConfigFactory;
+@Singleton
+public class SubmitRequirementExpressionsValidator {
private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
@Inject
- SubmitRequirementExpressionsValidator(
- DiffOperations diffOperations,
- ProjectConfig.Factory projectConfigFactory,
- SubmitRequirementsEvaluator submitRequirementsEvaluator) {
- this.diffOperations = diffOperations;
- this.projectConfigFactory = projectConfigFactory;
+ SubmitRequirementExpressionsValidator(SubmitRequirementsEvaluator submitRequirementsEvaluator) {
this.submitRequirementsEvaluator = submitRequirementsEvaluator;
}
- @Override
- public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
- throws CommitValidationException {
- try {
- if (!event.refName.equals(RefNames.REFS_CONFIG)
- || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
- // the project.config file in refs/meta/config was not modified, hence we do not need to
- // validate the submit requirements in it
- return ImmutableList.of();
- }
-
- ProjectConfig projectConfig = getProjectConfig(event);
- ImmutableList<CommitValidationMessage> validationMessages =
- validateSubmitRequirementExpressions(
- projectConfig.getSubmitRequirementSections().values());
- if (!validationMessages.isEmpty()) {
- throw new CommitValidationException(
- String.format(
- "invalid submit requirement expressions in %s (revision = %s)",
- ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
- validationMessages);
- }
- return ImmutableList.of();
- } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
- throw new CommitValidationException(
- String.format(
- "failed to validate submit requirement expressions in %s for revision %s in ref %s"
- + " of project %s",
- ProjectConfig.PROJECT_CONFIG,
- event.commit.getName(),
- RefNames.REFS_CONFIG,
- event.project.getNameKey()),
- e);
- }
- }
-
/**
- * Whether the given file was changed in the given revision.
+ * Validates the query expressions on the input {@code submitRequirement}.
*
- * @param receiveEvent the receive event
- * @param fileName the name of the file
+ * @return list of string containing the error messages resulting from the validation. The list is
+ * empty if the "submit requirement" is valid.
*/
- private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
- throws DiffNotAvailableException {
- return diffOperations
- .listModifiedFilesAgainstParent(
- receiveEvent.project.getNameKey(),
- receiveEvent.commit,
- /* parentNum=*/ 0,
- DiffOptions.DEFAULTS)
- .keySet().stream()
- .anyMatch(fileName::equals);
- }
-
- private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
- throws IOException, ConfigInvalidException {
- ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
- projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
- return projectConfig;
- }
-
- private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
- Collection<SubmitRequirement> submitRequirements) {
- List<CommitValidationMessage> validationMessages = new ArrayList<>();
- for (SubmitRequirement submitRequirement : submitRequirements) {
- validateSubmitRequirementExpression(
- validationMessages,
- submitRequirement,
- submitRequirement.submittabilityExpression(),
- ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
- submitRequirement
- .applicabilityExpression()
- .ifPresent(
- expression ->
- validateSubmitRequirementExpression(
- validationMessages,
- submitRequirement,
- expression,
- ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
- submitRequirement
- .overrideExpression()
- .ifPresent(
- expression ->
- validateSubmitRequirementExpression(
- validationMessages,
- submitRequirement,
- expression,
- ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
- }
+ public ImmutableList<String> validateExpressions(SubmitRequirement submitRequirement) {
+ List<String> validationMessages = new ArrayList<>();
+ validateSubmitRequirementExpression(
+ validationMessages,
+ submitRequirement,
+ submitRequirement.submittabilityExpression(),
+ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+ submitRequirement
+ .applicabilityExpression()
+ .ifPresent(
+ expression ->
+ validateSubmitRequirementExpression(
+ validationMessages,
+ submitRequirement,
+ expression,
+ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+ submitRequirement
+ .overrideExpression()
+ .ifPresent(
+ expression ->
+ validateSubmitRequirementExpression(
+ validationMessages,
+ submitRequirement,
+ expression,
+ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
return ImmutableList.copyOf(validationMessages);
}
private void validateSubmitRequirementExpression(
- List<CommitValidationMessage> validationMessages,
+ List<String> validationMessages,
SubmitRequirement submitRequirement,
SubmitRequirementExpression expression,
String configKey) {
@@ -160,23 +75,19 @@
submitRequirementsEvaluator.validateExpression(expression);
} catch (QueryParseException e) {
if (validationMessages.isEmpty()) {
- validationMessages.add(
- new CommitValidationMessage(
- "Invalid project configuration", ValidationMessage.Type.ERROR));
+ validationMessages.add("Invalid project configuration");
}
validationMessages.add(
- new CommitValidationMessage(
- String.format(
- " %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
- + " invalid: %s",
- ProjectConfig.PROJECT_CONFIG,
- expression.expressionString(),
- submitRequirement.name(),
- ProjectConfig.SUBMIT_REQUIREMENT,
- submitRequirement.name(),
- configKey,
- e.getMessage()),
- ValidationMessage.Type.ERROR));
+ String.format(
+ " %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+ + " invalid: %s",
+ ProjectConfig.PROJECT_CONFIG,
+ expression.expressionString(),
+ submitRequirement.name(),
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirement.name(),
+ configKey,
+ e.getMessage()));
}
}
}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementJson.java b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
new file mode 100644
index 0000000..5593ff4
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
@@ -0,0 +1,38 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.inject.Singleton;
+
+/** Converts a {@link SubmitRequirement} to a {@link SubmitRequirementInfo}. */
+@Singleton
+public class SubmitRequirementJson {
+ public static SubmitRequirementInfo format(SubmitRequirement sr) {
+ SubmitRequirementInfo info = new SubmitRequirementInfo();
+ info.name = sr.name();
+ info.description = sr.description().orElse(null);
+ if (sr.applicabilityExpression().isPresent()) {
+ info.applicabilityExpression = sr.applicabilityExpression().get().expressionString();
+ }
+ if (sr.overrideExpression().isPresent()) {
+ info.overrideExpression = sr.overrideExpression().get().expressionString();
+ }
+ info.submittabilityExpression = sr.submittabilityExpression().expressionString();
+ info.allowOverrideInChildProjects = sr.allowOverrideInChildProjects();
+ return info;
+ }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementResource.java b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
new file mode 100644
index 0000000..d075cd7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
@@ -0,0 +1,41 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class SubmitRequirementResource implements RestResource {
+ public static final TypeLiteral<RestView<SubmitRequirementResource>> SUBMIT_REQUIREMENT_KIND =
+ new TypeLiteral<>() {};
+
+ private final ProjectResource project;
+ private final SubmitRequirement submitRequirement;
+
+ public SubmitRequirementResource(ProjectResource project, SubmitRequirement submitRequirement) {
+ this.project = project;
+ this.submitRequirement = submitRequirement;
+ }
+
+ public ProjectResource getProject() {
+ return project;
+ }
+
+ public SubmitRequirement getSubmitRequirement() {
+ return submitRequirement;
+ }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index c234c8c..3789c42 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.project;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.metrics.Counter2;
@@ -29,6 +30,7 @@
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -38,6 +40,12 @@
@Singleton
public class SubmitRequirementsUtil {
+ /**
+ * Submit requirement name can only contain alphanumeric characters or hyphen. Name cannot start
+ * with a hyphen or number.
+ */
+ private static final Pattern SUBMIT_REQ_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9\\-]*");
+
@Singleton
static class Metrics {
final Counter2<String, String> submitRequirementsMatchingWithLegacy;
@@ -179,6 +187,20 @@
return ImmutableMap.copyOf(result);
}
+ /** Validates the name of submit requirements. */
+ public static void validateName(@Nullable String name) throws IllegalArgumentException {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("Empty submit requirement name");
+ }
+ if (!SUBMIT_REQ_NAME_PATTERN.matcher(name).matches()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Illegal submit requirement name \"%s\". Name can only consist of "
+ + "alphanumeric characters and '-'. Name cannot start with '-' or number.",
+ name));
+ }
+ }
+
private static boolean shouldReportMetric(ChangeData cd) {
// We only care about recording differences in old and new requirements for open changes
// that did not have their data retrieved from the (potentially stale) change index.
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
new file mode 100644
index 0000000..2aeba89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -0,0 +1,168 @@
+// 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 com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+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.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** A rest create view that creates a "submit requirement" for a project. */
+@Singleton
+public class CreateSubmitRequirement
+ implements RestCollectionCreateView<
+ ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
+ private final Provider<CurrentUser> user;
+ private final PermissionBackend permissionBackend;
+ private final MetaDataUpdate.User updateFactory;
+ private final ProjectConfig.Factory projectConfigFactory;
+ private final ProjectCache projectCache;
+ private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+ @Inject
+ public CreateSubmitRequirement(
+ Provider<CurrentUser> user,
+ PermissionBackend permissionBackend,
+ MetaDataUpdate.User updateFactory,
+ ProjectConfig.Factory projectConfigFactory,
+ ProjectCache projectCache,
+ SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+ this.user = user;
+ this.permissionBackend = permissionBackend;
+ this.updateFactory = updateFactory;
+ this.projectConfigFactory = projectConfigFactory;
+ this.projectCache = projectCache;
+ this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+ }
+
+ @Override
+ public Response<SubmitRequirementInfo> apply(
+ ProjectResource rsrc, IdString id, SubmitRequirementInput input)
+ throws AuthException, BadRequestException, IOException, PermissionBackendException {
+ if (!user.get().isIdentifiedUser()) {
+ throw new AuthException("Authentication required");
+ }
+
+ permissionBackend
+ .currentUser()
+ .project(rsrc.getNameKey())
+ .check(ProjectPermission.WRITE_CONFIG);
+
+ if (input == null) {
+ input = new SubmitRequirementInput();
+ }
+
+ if (input.name != null && !input.name.equals(id.get())) {
+ throw new BadRequestException("name in input must match name in URL");
+ }
+
+ try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+ ProjectConfig config = projectConfigFactory.read(md);
+
+ SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
+
+ md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
+ config.commit(md);
+
+ projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
+
+ return Response.created(SubmitRequirementJson.format(submitRequirement));
+ } catch (ConfigInvalidException e) {
+ throw new IOException("Failed to read project config", e);
+ } catch (ResourceConflictException e) {
+ throw new BadRequestException("Failed to create submit requirement", e);
+ }
+ }
+
+ public SubmitRequirement createSubmitRequirement(
+ ProjectConfig config, String name, SubmitRequirementInput input)
+ throws BadRequestException, ResourceConflictException {
+ validateSRName(name);
+ ensureSRUnique(name, config);
+ if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+ throw new BadRequestException("submittability_expression is required");
+ }
+ if (input.allowOverrideInChildProjects == null) {
+ // default is false
+ input.allowOverrideInChildProjects = false;
+ }
+ SubmitRequirement submitRequirement =
+ SubmitRequirement.builder()
+ .setName(name)
+ .setDescription(Optional.ofNullable(input.description))
+ .setApplicabilityExpression(
+ SubmitRequirementExpression.of(input.applicabilityExpression))
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(input.submittabilityExpression))
+ .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+ .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+ .build();
+
+ List<String> validationMessages =
+ submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+ if (!validationMessages.isEmpty()) {
+ throw new BadRequestException(
+ String.format("Invalid submit requirement input: %s", validationMessages));
+ }
+
+ config.upsertSubmitRequirement(submitRequirement);
+ return submitRequirement;
+ }
+
+ private void validateSRName(String name) throws BadRequestException {
+ try {
+ SubmitRequirementsUtil.validateName(name);
+ } catch (IllegalArgumentException e) {
+ throw new BadRequestException(e.getMessage(), e);
+ }
+ }
+
+ private void ensureSRUnique(String name, ProjectConfig config) throws ResourceConflictException {
+ for (String srName : config.getSubmitRequirementSections().keySet()) {
+ if (srName.equalsIgnoreCase(name)) {
+ throw new ResourceConflictException(
+ String.format(
+ "submit requirement \"%s\" conflicts with existing submit requirement \"%s\"",
+ name, srName));
+ }
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
new file mode 100644
index 0000000..ce482e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
@@ -0,0 +1,31 @@
+// 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 com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Singleton;
+
+/** A rest read view that retrieves a "submit requirement" for a project by its name. */
+@Singleton
+public class GetSubmitRequirement implements RestReadView<SubmitRequirementResource> {
+ @Override
+ public Response<SubmitRequirementInfo> apply(SubmitRequirementResource rsrc) {
+ return Response.ok(SubmitRequirementJson.format(rsrc.getSubmitRequirement()));
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index e50a494..1752b4ec 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -21,6 +21,7 @@
import static com.google.gerrit.server.project.FileResource.FILE_KIND;
import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.SubmitRequirementResource.SUBMIT_REQUIREMENT_KIND;
import static com.google.gerrit.server.project.TagResource.TAG_KIND;
import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
DynamicMap.mapOf(binder(), COMMIT_KIND);
DynamicMap.mapOf(binder(), TAG_KIND);
DynamicMap.mapOf(binder(), LABEL_KIND);
+ DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
@@ -78,6 +80,11 @@
delete(LABEL_KIND).to(DeleteLabel.class);
postOnCollection(LABEL_KIND).to(PostLabels.class);
+ child(PROJECT_KIND, "submit_requirements").to(SubmitRequirementsCollection.class);
+ create(SUBMIT_REQUIREMENT_KIND).to(CreateSubmitRequirement.class);
+ put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
+ get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
+
get(PROJECT_KIND, "HEAD").to(GetHead.class);
put(PROJECT_KIND, "HEAD").to(SetHead.class);
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
new file mode 100644
index 0000000..cd98bec
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
@@ -0,0 +1,85 @@
+// 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 com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubmitRequirementsCollection
+ implements ChildCollection<ProjectResource, SubmitRequirementResource> {
+ private final Provider<CurrentUser> user;
+ private final PermissionBackend permissionBackend;
+ private final DynamicMap<RestView<SubmitRequirementResource>> views;
+
+ @Inject
+ SubmitRequirementsCollection(
+ Provider<CurrentUser> user,
+ PermissionBackend permissionBackend,
+ DynamicMap<RestView<SubmitRequirementResource>> views) {
+ this.user = user;
+ this.permissionBackend = permissionBackend;
+ this.views = views;
+ }
+
+ @Override
+ public RestView<ProjectResource> list() throws RestApiException {
+ /** TODO(ghareeb): implement. */
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public SubmitRequirementResource parse(ProjectResource parent, IdString id)
+ throws AuthException, ResourceNotFoundException, PermissionBackendException {
+ if (!user.get().isIdentifiedUser()) {
+ throw new AuthException("Authentication required");
+ }
+
+ permissionBackend
+ .currentUser()
+ .project(parent.getNameKey())
+ .check(ProjectPermission.READ_CONFIG);
+
+ SubmitRequirement submitRequirement =
+ parent.getProjectState().getConfig().getSubmitRequirementSections().get(id.get());
+
+ if (submitRequirement == null) {
+ throw new ResourceNotFoundException(
+ String.format("Submit requirement '%s' does not exist", id));
+ }
+ return new SubmitRequirementResource(parent, submitRequirement);
+ }
+
+ @Override
+ public DynamicMap<RestView<SubmitRequirementResource>> views() {
+ return views;
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
new file mode 100644
index 0000000..a176bc4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -0,0 +1,156 @@
+// 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 com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+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.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A rest modify view that updates the definition of an existing submit requirement for a project.
+ */
+@Singleton
+public class UpdateSubmitRequirement
+ implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
+ private final Provider<CurrentUser> user;
+ private final PermissionBackend permissionBackend;
+ private final MetaDataUpdate.User updateFactory;
+ private final ProjectConfig.Factory projectConfigFactory;
+ private final ProjectCache projectCache;
+ private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+ @Inject
+ public UpdateSubmitRequirement(
+ Provider<CurrentUser> user,
+ PermissionBackend permissionBackend,
+ MetaDataUpdate.User updateFactory,
+ ProjectConfig.Factory projectConfigFactory,
+ ProjectCache projectCache,
+ SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+ this.user = user;
+ this.permissionBackend = permissionBackend;
+ this.updateFactory = updateFactory;
+ this.projectConfigFactory = projectConfigFactory;
+ this.projectCache = projectCache;
+ this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+ }
+
+ @Override
+ public Response<SubmitRequirementInfo> apply(
+ SubmitRequirementResource rsrc, SubmitRequirementInput input)
+ throws AuthException, BadRequestException, PermissionBackendException, IOException {
+ if (!user.get().isIdentifiedUser()) {
+ throw new AuthException("Authentication required");
+ }
+
+ permissionBackend
+ .currentUser()
+ .project(rsrc.getProject().getNameKey())
+ .check(ProjectPermission.WRITE_CONFIG);
+
+ if (input == null) {
+ input = new SubmitRequirementInput();
+ }
+
+ if (input.name != null && !input.name.equals(rsrc.getSubmitRequirement().name())) {
+ throw new BadRequestException("name in input must match name in URL");
+ }
+
+ try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+ ProjectConfig config = projectConfigFactory.read(md);
+
+ SubmitRequirement submitRequirement =
+ createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+
+ md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
+ config.commit(md);
+
+ projectCache.evict(rsrc.getProject().getNameKey());
+
+ return Response.created(SubmitRequirementJson.format(submitRequirement));
+ } catch (ConfigInvalidException e) {
+ throw new IOException("Failed to read project config", e);
+ } catch (ResourceConflictException e) {
+ throw new BadRequestException("Failed to create submit requirement", e);
+ }
+ }
+
+ public SubmitRequirement createSubmitRequirement(
+ ProjectConfig config, String name, SubmitRequirementInput input)
+ throws BadRequestException, ResourceConflictException {
+ validateSRName(name);
+ if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+ throw new BadRequestException("submittability_expression is required");
+ }
+ if (input.allowOverrideInChildProjects == null) {
+ // default is false
+ input.allowOverrideInChildProjects = false;
+ }
+ SubmitRequirement submitRequirement =
+ SubmitRequirement.builder()
+ .setName(name)
+ .setDescription(Optional.ofNullable(input.description))
+ .setApplicabilityExpression(
+ SubmitRequirementExpression.of(input.applicabilityExpression))
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(input.submittabilityExpression))
+ .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+ .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+ .build();
+
+ List<String> validationMessages =
+ submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+ if (!validationMessages.isEmpty()) {
+ throw new BadRequestException(
+ String.format("Invalid submit requirement input: %s", validationMessages));
+ }
+
+ config.upsertSubmitRequirement(submitRequirement);
+ return submitRequirement;
+ }
+
+ private void validateSRName(String name) throws BadRequestException {
+ try {
+ SubmitRequirementsUtil.validateName(name);
+ } catch (IllegalArgumentException e) {
+ throw new BadRequestException(e.getMessage(), e);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
new file mode 100644
index 0000000..b8f4f42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -0,0 +1,471 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+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.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsAPIIT extends AbstractDaemonTest {
+ @Inject private RequestScopeOperations requestScopeOperations;
+
+ @Test
+ public void cannotGetANonExistingSR() throws Exception {
+ ResourceNotFoundException thrown =
+ assertThrows(
+ ResourceNotFoundException.class,
+ () -> gApi.projects().name(project.get()).submitRequirement("code-review").get());
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo("Submit requirement 'code-review' does not exist");
+ }
+
+ @Test
+ public void getExistingSR() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.applicabilityExpression = "topic:foo";
+ input.submittabilityExpression = "label:code-review=+2";
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").get();
+ assertThat(info.name).isEqualTo("code-review");
+ assertThat(info.applicabilityExpression).isEqualTo("topic:foo");
+ assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+2");
+ assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+ }
+
+ @Test
+ public void updateSubmitRequirement() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.applicabilityExpression = "topic:foo";
+ input.submittabilityExpression = "label:code-review=+2";
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ input.submittabilityExpression = "label:code-review=+1";
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+ assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+1");
+ }
+
+ @Test
+ public void updateSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.applicabilityExpression = "topic:foo";
+ input.submittabilityExpression = "label:code-review=+2";
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ input.applicabilityExpression = null;
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+ assertThat(info.applicabilityExpression).isNull();
+ }
+
+ @Test
+ public void updateSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.overrideExpression = "topic:foo";
+ input.submittabilityExpression = "label:code-review=+2";
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ input.overrideExpression = null;
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+ assertThat(info.overrideExpression).isNull();
+ }
+
+ @Test
+ public void allowOverrideInChildProjectsDefaultsToFalse_updateSR() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.submittabilityExpression = "label:code-review=+2";
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ input.overrideExpression = "topic:foo";
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+ assertThat(info.allowOverrideInChildProjects).isFalse();
+ }
+
+ @Test
+ public void cannotUpdateSRAsAnonymousUser() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+ input.submittabilityExpression = "label:code-review=+1";
+ requestScopeOperations.setApiUserAnonymous();
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("code-review")
+ .update(new SubmitRequirementInput()));
+ assertThat(thrown).hasMessageThat().contains("Authentication required");
+ }
+
+ @Test
+ public void cannotUpdateSRtIfSRDoesNotExist() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ ResourceNotFoundException thrown =
+ assertThrows(
+ ResourceNotFoundException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo("Submit requirement 'code-review' does not exist");
+ }
+
+ @Test
+ public void cannotUpdateSRWithEmptySubmittableIf() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+ input.submittabilityExpression = null;
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+
+ assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+ }
+
+ @Test
+ public void cannotUpdateSRWithInvalidSubmittableIfExpression() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+ input.submittabilityExpression = "invalid_field:invalid_value";
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "Invalid submit requirement input: "
+ + "[Invalid project configuration, "
+ + "project.config: Expression 'invalid_field:invalid_value' of "
+ + "submit requirement 'code-review' "
+ + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+ + "Unsupported operator invalid_field:invalid_value]");
+ }
+
+ @Test
+ public void cannotUpdateSRWithInvalidOverrideIfExpression() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+ input.overrideExpression = "invalid_field:invalid_value";
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "Invalid submit requirement input: "
+ + "[Invalid project configuration, "
+ + "project.config: Expression 'invalid_field:invalid_value' of "
+ + "submit requirement 'code-review' "
+ + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+ + "Unsupported operator invalid_field:invalid_value]");
+ }
+
+ @Test
+ public void cannotUpdateSRWithInvalidApplicableIfExpression() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+ input.applicabilityExpression = "invalid_field:invalid_value";
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "Invalid submit requirement input: "
+ + "[Invalid project configuration, "
+ + "project.config: Expression 'invalid_field:invalid_value' of "
+ + "submit requirement 'code-review' "
+ + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+ + "Unsupported operator invalid_field:invalid_value]");
+ }
+
+ @Test
+ public void createSubmitRequirement() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+ input.submittabilityExpression = "label:code-review=+2";
+ input.overrideExpression = "label:build-cop-override=+1";
+ input.allowOverrideInChildProjects = true;
+
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ assertThat(info.name).isEqualTo("code-review");
+ assertThat(info.description).isEqualTo(input.description);
+ assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+ assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+ assertThat(info.submittabilityExpression).isEqualTo(input.submittabilityExpression);
+ assertThat(info.overrideExpression).isEqualTo(input.overrideExpression);
+ assertThat(info.allowOverrideInChildProjects).isEqualTo(true);
+ }
+
+ @Test
+ public void createSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "label:code-review=+2";
+ input.overrideExpression = "label:build-cop-override=+1";
+
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ assertThat(info.name).isEqualTo("code-review");
+ assertThat(info.applicabilityExpression).isNull();
+ }
+
+ @Test
+ public void createSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ assertThat(info.name).isEqualTo("code-review");
+ assertThat(info.overrideExpression).isNull();
+ }
+
+ @Test
+ public void allowOverrideInChildProjectsDefaultsToFalse_createSR() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ SubmitRequirementInfo info =
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+ assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+ }
+
+ @Test
+ public void cannotCreateSRAsAnonymousUser() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+ input.submittabilityExpression = "label:code-review=+2";
+ input.overrideExpression = "label:build-cop-override=+1";
+
+ requestScopeOperations.setApiUserAnonymous();
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("code-review")
+ .create(new SubmitRequirementInput()));
+ assertThat(thrown).hasMessageThat().contains("Authentication required");
+ }
+
+ @Test
+ public void cannotCreateSRtIfNameInInputDoesNotMatchResource() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("other-requirement")
+ .create(input)
+ .get());
+ assertThat(thrown).hasMessageThat().isEqualTo("name in input must match name in URL");
+ }
+
+ @Test
+ public void cannotCreateSRWithInvalidName() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "wrong$%";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("wrong$%")
+ .create(input)
+ .get());
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "Illegal submit requirement name \"wrong$%\". "
+ + "Name can only consist of alphanumeric characters and '-'."
+ + " Name cannot start with '-' or number.");
+ }
+
+ @Test
+ public void cannotCreateSRWithEmptySubmittableIf() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+ input.overrideExpression = "label:build-cop-override=+1";
+
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("code-review")
+ .create(input)
+ .get());
+
+ assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+ }
+
+ @Test
+ public void cannotCreateSRWithInvalidSubmittableIfExpression() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "invalid_field:invalid_value";
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("code-review")
+ .create(input)
+ .get());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "Invalid submit requirement input: "
+ + "[Invalid project configuration, "
+ + "project.config: Expression 'invalid_field:invalid_value' of "
+ + "submit requirement 'code-review' "
+ + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+ + "Unsupported operator invalid_field:invalid_value]");
+ }
+
+ @Test
+ public void cannotCreateSRWithInvalidOverrideIfExpression() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "label:Code-Review=+2";
+ input.overrideExpression = "invalid_field:invalid_value";
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("code-review")
+ .create(input)
+ .get());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "Invalid submit requirement input: "
+ + "[Invalid project configuration, "
+ + "project.config: Expression 'invalid_field:invalid_value' of "
+ + "submit requirement 'code-review' "
+ + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+ + "Unsupported operator invalid_field:invalid_value]");
+ }
+
+ @Test
+ public void cannotCreateSRWithInvalidApplicableIfExpression() throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.applicabilityExpression = "invalid_field:invalid_value";
+ input.submittabilityExpression = "label:Code-Review=+2";
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("code-review")
+ .create(input)
+ .get());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "Invalid submit requirement input: "
+ + "[Invalid project configuration, "
+ + "project.config: Expression 'invalid_field:invalid_value' of "
+ + "submit requirement 'code-review' "
+ + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+ + "Unsupported operator invalid_field:invalid_value]");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f1c0110..8879d53 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -32,6 +32,8 @@
import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.TagInput;
@@ -85,7 +87,8 @@
.build(),
RestCall.get("/projects/%s/dashboards"),
RestCall.put("/projects/%s/labels/new-label"),
- RestCall.post("/projects/%s/labels/"));
+ RestCall.post("/projects/%s/labels/"),
+ RestCall.put("/projects/%s/submit_requirements/new-sr"));
/**
* Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -175,6 +178,15 @@
// Label deletion must be tested last
RestCall.delete("/projects/%s/labels/%s"));
+ /**
+ * Submit requirement REST endpoints to be tested, each URL contains placeholders for the project
+ * identifier and the submit requirement name.
+ */
+ private static final ImmutableList<RestCall> SUBMIT_REQUIREMENT_ENDPOINTS =
+ ImmutableList.of(
+ RestCall.get("/projects/%s/submit_requirements/%s"),
+ RestCall.put("/projects/%s/submit_requirements/%s"));
+
private static final String FILENAME = "test.txt";
@Inject private ProjectOperations projectOperations;
@@ -236,6 +248,20 @@
RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
}
+ @Test
+ public void submitRequirementsEndpoints() throws Exception {
+ // Create the SR, so that the GET endpoint succeeds
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName("code-review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ RestApiCallHelper.execute(
+ adminRestSession, SUBMIT_REQUIREMENT_ENDPOINTS, project.get(), "code-review");
+ }
+
private String createAndSubmitChange(String filename) throws Exception {
RevCommit c =
testRepo
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
new file mode 100644
index 0000000..98ee71d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
@@ -0,0 +1,57 @@
+// 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.project;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test for {@link SubmitRequirementsUtil#validateName(String)}. */
+@RunWith(JUnit4.class)
+public class SubmitRequirementNameValidatorTest {
+ @Test
+ public void canStartWithSmallLetter() throws Exception {
+ SubmitRequirementsUtil.validateName("abc");
+ }
+
+ @Test
+ public void canStartWithCapitalLetter() throws Exception {
+ SubmitRequirementsUtil.validateName("Abc");
+ }
+
+ @Test
+ public void canBeEqualToOneLetter() throws Exception {
+ SubmitRequirementsUtil.validateName("a");
+ }
+
+ @Test
+ public void cannotStartWithNumber() throws Exception {
+ assertThrows(
+ IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("98abc"));
+ }
+
+ @Test
+ public void cannotStartWithHyphen() throws Exception {
+ assertThrows(IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("-abc"));
+ }
+
+ @Test
+ public void cannotContainNonAlphanumericOrHyphen() throws Exception {
+ assertThrows(
+ IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("a&^bc"));
+ }
+}