Merge "CacheBasedWebSession: Remove misplaced RequestScoped annotation"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index d83ef0e..be24bc7 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2818,6 +2818,53 @@
   }
 ----
 
+[[check-submit-requirement]]
+=== Check Submit Requirement
+--
+'POST /changes/link:#change-id[\{change-id\}]/check.submit_requirement'
+--
+
+Tests a submit requirement and returns the result as a
+link:#submit-requirement-result-info[SubmitRequirementResultInfo]. The request
+body must contain a link:#submit-requirement-input[SubmitRequirementInput].
+
+Note that this endpoint does not modify the change resource.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+As response a link:#submit-requirement-result-info[SubmitRequirementResultInfo]
+entity is returned that describes the submit requirement result.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -8232,6 +8279,32 @@
 contains the list of predicates that are not fulfilled for the change.
 |===========================
 
+[[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
+`true`.
+|===========================
+
 [[submit-requirement-result-info]]
 === SubmitRequirementResultInfo
 The `SubmitRequirementResultInfo` describes the result of evaluating a
@@ -8246,7 +8319,7 @@
 Description of the submit requirement.
 |`status`||
 Status describing the result of evaluating the submit requirement. The status
-is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`).
+is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`).
 |`is_legacy`||
 If true, this submit requirement result was created from a legacy
 link:#submit-record[SubmitRecord]. Otherwise, it was created by evaluating a
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 690ba4e..2224649 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -29,6 +29,8 @@
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -425,6 +427,10 @@
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
+  /** Returns the result of evaluating the {@link SubmitRequirementInput} input on the change. */
+  SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
+      throws RestApiException;
+
   void index() throws RestApiException;
 
   /** Check if this change is a pure revert of the change stored in revertOf. */
@@ -761,6 +767,12 @@
     }
 
     @Override
+    public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void index() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInput.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInput.java
new file mode 100644
index 0000000..96045d4
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInput.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2021 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;
+
+/** API Input describing a submit requirement entity. */
+public class SubmitRequirementInput {
+  /** Submit requirement name. */
+  public String name;
+
+  /** Submit requirement description. */
+  public String description;
+
+  /**
+   * Query expression that can be evaluated on any change. If evaluated to true on a change, the
+   * submit requirement is then applicable on this change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * 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.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * 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.
+   */
+  public String overrideExpression;
+
+  /** Whether this submit requirement can be overridden in child projects. */
+  public Boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
index d17da0a..3d50f13 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -35,7 +35,13 @@
      * Submit requirement is not applicable for the change. Happens when {@code
      * applicabilityExpressionResult} is not fulfilled.
      */
-    NOT_APPLICABLE
+    NOT_APPLICABLE,
+
+    /**
+     * Any of the applicability, submittability or override expressions contain invalid syntax and
+     * are not parsable.
+     */
+    ERROR
   }
 
   /** Submit requirement name. */
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index cbaf49e..a49061d 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -56,6 +56,8 @@
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -74,6 +76,7 @@
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
 import com.google.gerrit.server.restapi.change.Check;
+import com.google.gerrit.server.restapi.change.CheckSubmitRequirement;
 import com.google.gerrit.server.restapi.change.CreateMergePatchSet;
 import com.google.gerrit.server.restapi.change.DeleteAssignee;
 import com.google.gerrit.server.restapi.change.DeleteChange;
@@ -164,6 +167,7 @@
   private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
+  private final CheckSubmitRequirement checkSubmitRequirement;
   private final Index index;
   private final Move move;
   private final PostPrivate postPrivate;
@@ -218,6 +222,7 @@
       Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
+      CheckSubmitRequirement checkSubmitRequirement,
       Index index,
       Move move,
       PostPrivate postPrivate,
@@ -270,6 +275,7 @@
     this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
+    this.checkSubmitRequirement = checkSubmitRequirement;
     this.index = index;
     this.move = move;
     this.postPrivate = postPrivate;
@@ -708,6 +714,16 @@
   }
 
   @Override
+  public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
+      throws RestApiException {
+    try {
+      return checkSubmitRequirement.apply(change, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check submit requirement", e);
+    }
+  }
+
+  @Override
   public void index() throws RestApiException {
     try {
       index.apply(change, new Input());
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index db25dc7..9a94d93 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -61,8 +61,6 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirement;
-import com.google.gerrit.entities.SubmitRequirementExpression;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -80,7 +78,6 @@
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.SubmitRecordInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
@@ -378,11 +375,11 @@
     return submitRecordInfos;
   }
 
-  private static Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
+  private Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
     Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
     Map<SubmitRequirement, SubmitRequirementResult> requirements = cd.submitRequirements();
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> entry : requirements.entrySet()) {
-      reqInfos.add(submitRequirementToInfo(entry.getKey(), entry.getValue()));
+      reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue()));
     }
     return reqInfos;
   }
@@ -420,39 +417,6 @@
     return info;
   }
 
-  private static SubmitRequirementResultInfo submitRequirementToInfo(
-      SubmitRequirement req, SubmitRequirementResult result) {
-    SubmitRequirementResultInfo info = new SubmitRequirementResultInfo();
-    info.name = req.name();
-    info.description = req.description().orElse(null);
-    if (req.applicabilityExpression().isPresent()) {
-      info.applicabilityExpressionResult =
-          submitRequirementExpressionToInfo(
-              req.applicabilityExpression().get(), result.applicabilityExpressionResult().get());
-    }
-    if (req.overrideExpression().isPresent()) {
-      info.overrideExpressionResult =
-          submitRequirementExpressionToInfo(
-              req.overrideExpression().get(), result.overrideExpressionResult().get());
-    }
-    info.submittabilityExpressionResult =
-        submitRequirementExpressionToInfo(
-            req.submittabilityExpression(), result.submittabilityExpressionResult());
-    info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
-    info.isLegacy = result.legacy();
-    return info;
-  }
-
-  private static SubmitRequirementExpressionInfo submitRequirementExpressionToInfo(
-      SubmitRequirementExpression expression, SubmitRequirementExpressionResult result) {
-    SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
-    info.expression = expression.expressionString();
-    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
-    info.passingAtoms = result.passingAtoms();
-    info.failingAtoms = result.failingAtoms();
-    return info;
-  }
-
   private static void finish(ChangeInfo info) {
     info.id =
         Joiner.on('~')
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
new file mode 100644
index 0000000..ebb0790
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 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.change;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+
+/**
+ * Produces submit requirements related entities like {@link SubmitRequirementResultInfo}s, which
+ * are serialized to JSON afterwards.
+ */
+public class SubmitRequirementsJson {
+  private SubmitRequirementsJson() {}
+
+  public static SubmitRequirementResultInfo toInfo(
+      SubmitRequirement req, SubmitRequirementResult result) {
+    SubmitRequirementResultInfo info = new SubmitRequirementResultInfo();
+    info.name = req.name();
+    info.description = req.description().orElse(null);
+    if (req.applicabilityExpression().isPresent()) {
+      info.applicabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.applicabilityExpression().get(), result.applicabilityExpressionResult().get());
+    }
+    if (req.overrideExpression().isPresent()) {
+      info.overrideExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.overrideExpression().get(), result.overrideExpressionResult().get());
+    }
+    info.submittabilityExpressionResult =
+        submitRequirementExpressionToInfo(
+            req.submittabilityExpression(), result.submittabilityExpressionResult());
+    info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
+    info.isLegacy = result.legacy();
+    return info;
+  }
+
+  private static SubmitRequirementExpressionInfo submitRequirementExpressionToInfo(
+      SubmitRequirementExpression expression, SubmitRequirementExpressionResult result) {
+    SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
+    info.expression = expression.expressionString();
+    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.passingAtoms = result.passingAtoms();
+    info.failingAtoms = result.failingAtoms();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
new file mode 100644
index 0000000..15728ce
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2021 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.change;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+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.change.ChangeResource;
+import com.google.gerrit.server.change.SubmitRequirementsJson;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.inject.Inject;
+import java.util.Optional;
+
+/**
+ * A rest view to evaluate (test) a {@link com.google.gerrit.entities.SubmitRequirement} on a given
+ * change.
+ *
+ * <p>TODO(ghareeb): Can this class be made singleton?
+ */
+public class CheckSubmitRequirement
+    implements RestModifyView<ChangeResource, SubmitRequirementInput> {
+  private final SubmitRequirementsEvaluator evaluator;
+
+  @Inject
+  public CheckSubmitRequirement(SubmitRequirementsEvaluator evaluator) {
+    this.evaluator = evaluator;
+  }
+
+  @Override
+  public Response<SubmitRequirementResultInfo> apply(
+      ChangeResource resource, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    SubmitRequirement requirement = createSubmitRequirement(input);
+    SubmitRequirementResult res =
+        evaluator.evaluateRequirement(requirement, resource.getChangeData());
+    return Response.ok(SubmitRequirementsJson.toInfo(requirement, res));
+  }
+
+  private SubmitRequirement createSubmitRequirement(SubmitRequirementInput input)
+      throws BadRequestException {
+    validateSubmitRequirementInput(input);
+    return SubmitRequirement.builder()
+        .setName(input.name)
+        .setDescription(Optional.ofNullable(input.description))
+        .setApplicabilityExpression(SubmitRequirementExpression.of(input.applicabilityExpression))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(input.submittabilityExpression))
+        .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+        .setAllowOverrideInChildProjects(
+            input.allowOverrideInChildProjects == null ? true : input.allowOverrideInChildProjects)
+        .build();
+  }
+
+  private void validateSubmitRequirementInput(SubmitRequirementInput input)
+      throws BadRequestException {
+    if (input.name == null) {
+      throw new BadRequestException("Field 'name' is missing from input.");
+    }
+    if (input.submittabilityExpression == null) {
+      throw new BadRequestException("Field 'submittability_expression' is missing from input.");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 000a17e..4d955fb 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -123,6 +123,7 @@
     post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
+    post(CHANGE_KIND, "check.submit_requirement").to(CheckSubmitRequirement.class);
 
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 52202d7..e657c89 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -153,6 +153,7 @@
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.SubmitRecordInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
@@ -4074,6 +4075,90 @@
   }
 
   @Test
+  public void checkSubmitRequirement_satisfied() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_notApplicable() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ "branch:non-existent",
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ null);
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void checkSubmitRequirement_overridden() throws Exception {
+    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Override-Label")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ null,
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ "label:Override-Label=+1");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+
+    voteLabel(changeId, "Override-Label", 1);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void checkSubmitRequirement_error() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+  }
+
+  @Test
   public void submitRequirement_withLabelEqualsMax() throws Exception {
     configSubmitRequirement(
         project,
@@ -5393,4 +5478,22 @@
       return Optional.of(record);
     }
   }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String submittabilityExpression) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submittabilityExpression;
+    return input;
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String applicableIf, String submittableIf, String overrideIf) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.applicabilityExpression = applicableIf;
+    input.submittabilityExpression = submittableIf;
+    input.overrideExpression = overrideIf;
+    return input;
+  }
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index ee579ff..905d6be 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -55,7 +55,7 @@
 /**
  * Represents a "generic" text range in the code (e.g. text selection)
  */
-interface TextRange {
+export declare interface TextRange {
   /** first line of the range (1-based inclusive). */
   start_line: number;
   /** first column of the range (in the first line) (1-based inclusive). */
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 61785fc..e3edb5e 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -31,7 +31,6 @@
   ApprovalInfo,
   Reviewers,
   AccountId,
-  DetailedLabelInfo,
   EmailAddress,
   AccountDetailInfo,
   isDetailedLabelInfo,
@@ -158,24 +157,19 @@
     if (!change.labels) {
       return NaN;
     }
-    const detailedLabel = change.labels[label] as DetailedLabelInfo;
-    if (!detailedLabel.all) {
+    const detailedLabel = change.labels[label];
+    if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
       return NaN;
     }
-    const detailed = detailedLabel.all
-      .filter(
-        (approval: ApprovalInfo) =>
-          reviewer._account_id === approval._account_id
-      )
-      .pop();
-    if (!detailed) {
+    const approvalInfo = getApprovalInfo(detailedLabel, reviewer);
+    if (!approvalInfo) {
       return NaN;
     }
-    if (hasOwnProperty(detailed, 'permitted_voting_range')) {
-      if (!detailed.permitted_voting_range) return NaN;
-      return detailed.permitted_voting_range.max;
-    } else if (hasOwnProperty(detailed, 'value')) {
-      // If preset, user can vote on the label.
+    if (hasOwnProperty(approvalInfo, 'permitted_voting_range')) {
+      if (!approvalInfo.permitted_voting_range) return NaN;
+      return approvalInfo.permitted_voting_range.max;
+    } else if (hasOwnProperty(approvalInfo, 'value')) {
+      // If present, user can vote on the label.
       return 0;
     }
     return NaN;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index d3f23ad..bf15bb5 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -26,6 +26,7 @@
 import {
   createAccountDetailWithId,
   createChange,
+  createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -423,6 +424,7 @@
       ...createChange(),
       labels: {
         Foo: {
+          ...createDetailedLabelInfo(),
           all: [
             {
               _account_id: 7 as AccountId,
@@ -431,6 +433,7 @@
           ],
         },
         Bar: {
+          ...createDetailedLabelInfo(),
           all: [
             {
               ...createAccountDetailWithId(1),
@@ -443,6 +446,7 @@
           ],
         },
         FooBar: {
+          ...createDetailedLabelInfo(),
           all: [{_account_id: 7 as AccountId, value: 0}],
         },
       },
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 3c9f54c..d90d173 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -120,6 +120,9 @@
           margin-top: var(--spacing-m);
           padding: var(--spacing-m) var(--spacing-xl) 0;
         }
+        .status-placeholder {
+          visibility: hidden;
+        }
       `,
     ];
   }
@@ -155,31 +158,27 @@
 
   private renderLabelSection() {
     const labels = this.computeLabels();
+    const showLabelName = labels.length >= 2;
     return html` <div class="section">
-      ${labels.map(l => this.renderLabel(l))}
+      <div class="sectionIcon"></div>
+      <div class="row">
+        <!-- Hidden placeholder to be aligned as Status line above -->
+        <div class="title status-placeholder">Status</div>
+        <div>${labels.map(l => this.renderLabel(l, showLabelName))}</div>
+      </div>
     </div>`;
   }
 
-  private renderLabel(label: Label) {
+  private renderLabel(label: Label, showLabelName: boolean) {
     return html`
-      <section class="label">
-        <div class="label-title">
-          <gr-limited-text
-            class="name"
-            limit="25"
-            text="${label.labelName}"
-          ></gr-limited-text>
-        </div>
-        <div class="label-value">
-          <gr-label-info
-            .change=${this.change}
-            .account=${this.account}
-            .mutable=${this.mutable}
-            .label="${label.labelName}"
-            .labelInfo="${label.labelInfo}"
-          ></gr-label-info>
-        </div>
-      </section>
+      ${showLabelName ? html`<div>${label.labelName} votes</div>` : ''}
+      <gr-label-info
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+        .label="${label.labelName}"
+        .labelInfo="${label.labelInfo}"
+      ></gr-label-info>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 2df2ccb..f60b9b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -17,8 +17,10 @@
 import '../../../styles/gr-font-styles';
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
+import '../gr-vote-chip/gr-vote-chip';
 import '../gr-account-label/gr-account-label';
 import '../gr-account-link/gr-account-link';
+import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../gr-label/gr-label';
@@ -36,7 +38,12 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {GrButton} from '../gr-button/gr-button';
-import {getVotingRangeOrDefault} from '../../../utils/label-util';
+import {
+  canVote,
+  getApprovalInfo,
+  getVotingRangeOrDefault,
+  hasNeutralStatus,
+} from '../../../utils/label-util';
 import {appContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -44,6 +51,7 @@
 import {votingStyles} from '../../../styles/gr-voting-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {fireReload} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -160,28 +168,83 @@
         .labelValueContainer:not(:first-of-type) td {
           padding-top: var(--spacing-s);
         }
+        .reviewer-row {
+          padding-top: var(--spacing-s);
+        }
+        .reviewer-row:first-of-type {
+          padding-top: 0;
+        }
+        .reviewer-row gr-account-chip,
+        .reviewer-row gr-tooltip-content {
+          display: inline-block;
+          vertical-align: top;
+        }
+        .reviewer-row .no-votes {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-xs);
+        }
       `,
     ];
   }
 
+  private readonly flagsService = appContext.flagsService;
+
   override render() {
+    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+      return this.renderNewSubmitRequirements();
+    } else {
+      return this.renderOldSubmitRequirements();
+    }
+  }
+
+  private renderNewSubmitRequirements() {
+    const labelInfo = this.labelInfo;
+    if (!labelInfo) return;
+    const reviewers = (this.change?.reviewers['REVIEWER'] ?? []).filter(
+      reviewer => canVote(labelInfo, reviewer)
+    );
+    return html`<div>
+      ${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
+    </div>`;
+  }
+
+  private renderOldSubmitRequirements() {
+    const labelInfo = this.labelInfo;
     return html` <p
         class="placeholder ${this.computeShowPlaceholder(
-          this.labelInfo,
+          labelInfo,
           this.change?.labels
         )}"
       >
         No votes
       </p>
       <table>
-        ${this.mapLabelInfo(
-          this.labelInfo,
-          this.account,
-          this.change?.labels
-        ).map(mappedLabel => this.renderLabel(mappedLabel))}
+        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
+          mappedLabel => this.renderLabel(mappedLabel)
+        )}
       </table>`;
   }
 
+  renderReviewerVote(reviewer: AccountInfo) {
+    const labelInfo = this.labelInfo;
+    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
+    const approvalInfo = getApprovalInfo(labelInfo, reviewer);
+    const noVoteYet =
+      !approvalInfo || hasNeutralStatus(labelInfo, approvalInfo);
+    return html`<div class="reviewer-row">
+      <gr-account-chip .account="${reviewer}" .change="${this.change}">
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote="${approvalInfo}"
+          .label="${labelInfo}"
+        ></gr-vote-chip
+      ></gr-account-chip>
+      ${noVoteYet
+        ? html`<span class="no-votes">No votes</span>`
+        : html`${this.renderRemoveVote(reviewer)}`}
+    </div>`;
+  }
+
   renderLabel(mappedLabel: FormattedLabel) {
     const {labelInfo, change} = this;
     return html` <tr class="labelValueContainer">
@@ -201,26 +264,28 @@
           .change="${change}"
         ></gr-account-link>
       </td>
-      <td>
-        <gr-tooltip-content has-tooltip title="Remove vote">
-          <gr-button
-            link
-            aria-label="Remove vote"
-            @click="${this.onDeleteVote}"
-            data-account-id="${ifDefined(mappedLabel.account._account_id)}"
-            class="deleteBtn ${this.computeDeleteClass(
-              mappedLabel.account,
-              this.mutable,
-              change
-            )}"
-          >
-            <iron-icon icon="gr-icons:delete"></iron-icon>
-          </gr-button>
-        </gr-tooltip-content>
-      </td>
+      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
     </tr>`;
   }
 
+  private renderRemoveVote(reviewer: AccountInfo) {
+    return html`<gr-tooltip-content has-tooltip title="Remove vote">
+      <gr-button
+        link
+        aria-label="Remove vote"
+        @click="${this.onDeleteVote}"
+        data-account-id="${ifDefined(reviewer._account_id)}"
+        class="deleteBtn ${this.computeDeleteClass(
+          reviewer,
+          this.mutable,
+          this.change
+        )}"
+      >
+        <iron-icon icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+    </gr-tooltip-content>`;
+  }
+
   /**
    * This method also listens on change.labels.*,
    * to trigger computation when a label is removed from the change.
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index db84043..351cc13 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -102,6 +102,7 @@
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 import {GenerateUrlEditViewParameters} from '../elements/core/gr-navigation/gr-navigation';
 import {
+  DetailedLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
@@ -706,3 +707,13 @@
     message: 'This is the test message.',
   };
 }
+
+export function createDetailedLabelInfo(): DetailedLabelInfo {
+  return {
+    values: {
+      ' 0': 'No score',
+      '+1': 'Style Verified',
+      '-1': 'Wrong Style or Formatting',
+    },
+  };
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 78c4132..960b02f 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -134,6 +134,16 @@
   return label.all?.filter(x => x._account_id === account._account_id)[0];
 }
 
+export function canVote(label: DetailedLabelInfo, account: AccountInfo) {
+  const approvalInfo = getApprovalInfo(label, account);
+  if (!approvalInfo) return false;
+  if (approvalInfo.permitted_voting_range) {
+    return approvalInfo.permitted_voting_range.max > 0;
+  }
+  // If value present, user can vote on the label.
+  return approvalInfo.value !== undefined;
+}
+
 export function getAllUniqueApprovals(labelInfo?: LabelInfo) {
   if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return [];
   const uniqueApprovals = (labelInfo.all ?? [])
@@ -148,8 +158,7 @@
 export function hasVotes(labelInfo: LabelInfo): boolean {
   if (isDetailedLabelInfo(labelInfo)) {
     return (labelInfo.all ?? []).some(
-      approval =>
-        getLabelStatus(labelInfo, approval.value) !== LabelStatus.NEUTRAL
+      approval => !hasNeutralStatus(labelInfo, approval)
     );
   }
   if (isQuickLabelInfo(labelInfo)) {