Merge "Support enabling WANT_LGTM=all via submit requirements"
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 5ab1add..c258b96 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -473,6 +473,38 @@
 +
 `distinctvoters:[Code-Review,Trust,API-Review],count>2`
 
+[[operator_label_with_users_arg]]
+label:'<label><operator><value>,users=human_reviewers'::
++
+Extension of the link:user-search.html#labels[label] predicate that
+allows matching changes that have a matching vote from all human
+reviewers. Votes from service users (members of the
+link:access-control.html#service_users[Service Users] group) and the
+change owner are ignored.
++
+If link:config-project-config.html#reviewer.enableByEmail[reviewers by
+email] are present then "user=all_reviewers" doesn't match if the
+expected value is other than 0. Reviewers by email are reviewers that
+don't have a Gerrit account.  Without Gerrit account they cannot vote
+on the change, which means changes that have any such reviewers never
+match when a vote from all reviewers is expected.
++
+If a change has no human reviewers, this operator doesn't match
+(because a human review is required but no human reviewer is present).
++
+Examples:
+`label:Code-Review=MAX,users=human_reviewers`
++
+`label:Code-Review>=1,users=human_reviewers`
++
+The 'users' arg cannot be combined with other arguments ('count',
+'user', 'group').
++
+'label:Code-Review=MAX,users=human_reviewers' can be used to
+implement "Want-Code-Review-From-All" functionaly, see
+link#require-code-review-approvals-from-all-human-reviewers-example[examples
+below].
+
 [[operator_is_true]]
 is:true::
 +
@@ -557,7 +589,7 @@
 == Examples
 
 [[code-review-example]]
-=== Code-Review Example
+=== Require Code-Review approval from a non-uploader
 
 To define a submit requirement for code-review that requires a maximum vote for
 the “Code-Review” label from a non-uploader without a maximum negative vote:
@@ -571,7 +603,7 @@
 ----
 
 [[exempt-branch-example]]
-=== Exempt a branch Example
+=== Exempt a branch
 
 We could exempt a submit requirement from certain branches. For example,
 project owners might want to skip the 'Code-Style' requirement from the
@@ -602,7 +634,7 @@
 ----
 
 [[require-footer-example]]
-=== Require a footer Example
+=== Require a footer
 
 It's possible to use a submit requirement to require a footer to be present in
 the commit message.
@@ -614,6 +646,51 @@
   submittableIf = hasfooter:\"Bug\"
 ----
 
+[[require-code-review-approvals-from-all-human-reviewers-example]]
+=== Require Code-Review approvals from all human reviewers
+
+The following submit requirement requires a 'Code-Review+MAX' approval
+from all human reviewers of the change. Votes from service users
+(members of the link:access-control.html#service_users[Service Users]
+group) and the change owner are ignored.
+
+The 'applicableIf' condition makes this submit requirement show up in
+the UI only if it is not satisfied.
+
+If a change has no human reviewers, this submit requirement is
+unsatisfied (because a human review is required but no human reviewer
+is present).
+
+----
+[submit-requirement "Want-Code-Review-From-All"]
+  description = A maximum 'Code-Review' vote is required from all \
+                human reviewers (service users that are reviewers \
+                are ignored).
+  applicableIf = -label:Code-Review=MAX,users=human_reviewers
+  submittableIf = label:Code-Review=MAX,users=human_reviewers
+----
+
+It is possible to configure the 'Want-Code-Review-From-All' submit
+requirement so that it only applies when a 'Want-LGTM: all' footer is
+present in the commit message. This way users can enable this submit
+requirement on demand by including this footer into their commit
+messages.
+
+Note, the footer key cannot contain underscores (e.g. using
+'Want_LGTM: all' as the footer does not work).
+
+----
+[submit-requirement "Want-Code-Review-From-All"]
+  description = A maximum 'Code-Review' vote is required from all \
+                human reviewers (service users that are reviewers \
+                are ignored).
+  applicableIf = footer:\"WANT-LGTM: all\" -label:Code-Review=MAX,users=human_reviewers
+  submittableIf = label:Code-Review=MAX,users=human_reviewers
+----
+
+For more information about the "users=human_reviewers" arg see
+link:#operator_label_with_users_arg[above].
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index f75984d..f30efd4 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -98,6 +98,7 @@
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -201,6 +202,7 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
+    factory(SubmitRequirementLabelExtensionPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 4ad8fa7..1f0bd6e 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -218,6 +218,7 @@
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
@@ -301,6 +302,7 @@
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
+    factory(SubmitRequirementLabelExtensionPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
     factory(EmailNewPatchSet.Factory.class);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d598739..9b85582 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -236,6 +236,7 @@
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
   public static final String ARG_ID_NON_CONTRIBUTOR = "non_contributor";
   public static final String ARG_COUNT = "count";
+  public static final String ARG_USERS = "users";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
   public static final Account.Id NON_CONTRIBUTOR_ACCOUNT_ID = Account.id(-2);
@@ -1108,6 +1109,9 @@
                     "count=%d is not allowed. Maximum allowed value for count is %d.",
                     count, LabelPredicate.MAX_COUNT));
           }
+        } else if (key.equalsIgnoreCase(ARG_USERS)) {
+          throw new QueryParseException(
+              String.format("Cannot use the '%s' argument in search", ARG_USERS));
         } else {
           throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index cb92ddd..55d3505 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -28,13 +28,16 @@
 import com.google.gerrit.server.submitrequirement.predicate.RegexAuthorEmailPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.RegexCommitterEmailPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.RegexUploaderEmailPredicateFactory;
+import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
  * A query builder for submit requirement expressions that includes all {@link ChangeQueryBuilder}
@@ -48,6 +51,8 @@
       new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
 
   private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+  private final SubmitRequirementLabelExtensionPredicate.Factory
+      submitRequirementLabelExtensionPredicateFactory;
   private final HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory;
 
   /**
@@ -70,11 +75,15 @@
   SubmitRequirementChangeQueryBuilder(
       Arguments args,
       DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
+      SubmitRequirementLabelExtensionPredicate.Factory
+          submitRequirementLabelExtensionPredicateFactory,
       FileEditsPredicate.Factory fileEditsPredicateFactory,
       HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory,
       RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory) {
     super(def, args);
     this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
+    this.submitRequirementLabelExtensionPredicateFactory =
+        submitRequirementLabelExtensionPredicateFactory;
     this.fileEditsPredicateFactory = fileEditsPredicateFactory;
     this.hasSubmoduleUpdateFactory = hasSubmoduleUpdateFactory;
     this.regexUploaderEmailPredicateFactory = regexUploaderEmailPredicateFactory;
@@ -150,6 +159,16 @@
     return distinctVotersPredicateFactory.create(value);
   }
 
+  @Override
+  public Predicate<ChangeData> label(String value)
+      throws QueryParseException, IOException, ConfigInvalidException {
+    if (SubmitRequirementLabelExtensionPredicate.matches(value)) {
+      return submitRequirementLabelExtensionPredicateFactory.create(value);
+    }
+    SubmitRequirementLabelExtensionPredicate.validateIfNoMatch(value);
+    return super.label(value);
+  }
+
   /**
    * A SR operator that can match with file path and content pattern. The value should be of the
    * form:
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java
new file mode 100644
index 0000000..3a59394
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java
@@ -0,0 +1,211 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Enums;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gerrit.server.query.change.LabelPredicate;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Extensions of the {@link LabelPredicate} that are only available for submit requirement
+ * expressions, but not for search.
+ *
+ * <p>Supported extensions:
+ *
+ * <ul>
+ *   <li>"users=human_reviewers" arg, e.g. "label:Code-Review=MAX,users=human_reviewers" matches
+ *       changes where all human reviewers have approved the change with Code-Review=MAX
+ * </ul>
+ */
+public class SubmitRequirementLabelExtensionPredicate extends SubmitRequirementPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    SubmitRequirementLabelExtensionPredicate create(String value) throws QueryParseException;
+  }
+
+  private static final Pattern PATTERN = Pattern.compile("(?<label>[^,]*),users=human_reviewers$");
+  private static final Pattern PATTERN_LABEL =
+      Pattern.compile("(?<label>[^,<>=]*)(?<op>=|<=|>=|<|>)(?<value>[^,]*)");
+
+  public static boolean matches(String value) {
+    return PATTERN.matcher(value).matches();
+  }
+
+  public static void validateIfNoMatch(String value) throws QueryParseException {
+    if (value.contains(",users=")) {
+      throw new QueryParseException(
+          "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+              + " group')");
+    }
+  }
+
+  private final Arguments args;
+  private final ServiceUserClassifier serviceUserClassifier;
+  private final String label;
+
+  @Inject
+  public SubmitRequirementLabelExtensionPredicate(
+      Arguments args, ServiceUserClassifier serviceUserClassifier, @Assisted String value)
+      throws QueryParseException {
+    super("label", value);
+    this.args = args;
+    this.serviceUserClassifier = serviceUserClassifier;
+
+    Matcher m = PATTERN.matcher(value);
+    if (!m.matches()) {
+      throw new QueryParseException(
+          String.format("invalid value for '%s': %s", getOperator(), value));
+    }
+    this.label = validateLabel(m.group("label"));
+  }
+
+  private String validateLabel(String label) throws QueryParseException {
+    int eq = label.indexOf('=');
+
+    if (eq <= 0) {
+      return label;
+    }
+
+    String statusName = label.substring(eq + 1).toUpperCase(Locale.US);
+    SubmitRecord.Label.Status status =
+        Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
+    if (status != null) {
+      // We would need to use SubmitRecordPredicate but can't because it doesn't implement
+      // Matchable.
+      throw new QueryParseException(
+          "Cannot use the 'users=human_reviewers' argument in conjunction with a submit record"
+              + " label status");
+    }
+    return label;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    if (!cd.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).isEmpty()
+        && !matchZeroVotes(label)) {
+      // Reviewers by email are reviewers that don't have a Gerrit account. Without Gerrit
+      // account they cannot vote on the change, which means changes that have any such
+      // reviewers never match when we expect a vote != 0 from all reviewers.
+      logger.atFine().log(
+          "change %s doesn't match since there are reviewers by email"
+              + " (that don't have a matching approval): %s",
+          cd.change().getChangeId(), cd.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      return false;
+    }
+
+    ImmutableSet<Account.Id> humanReviewers =
+        cd.reviewers().byState(ReviewerStateInternal.REVIEWER).stream()
+            // Ignore the change owner (if the change owner voted on their own change they are
+            // technically a reviewer).
+            .filter(accountId -> !accountId.equals(cd.change().getOwner()))
+            // Ignore reviewers that are service users.
+            .filter(accountId -> !serviceUserClassifier.isServiceUser(accountId))
+            .collect(toImmutableSet());
+
+    if (humanReviewers.isEmpty()) {
+      // a review from human reviewers is required, but no human reviewers are present
+      return false;
+    }
+
+    for (Account.Id reviewer : humanReviewers) {
+      if (!new LabelPredicate(
+              args,
+              label,
+              ImmutableSet.of(reviewer),
+              /* group= */ null,
+              /* count= */ null,
+              /* countOp= */ null)
+          .match(cd)) {
+        logger.atFine().log(
+            "change %s doesn't match because it misses matching approvals from: %s",
+            cd.change().getChangeId(), reviewer);
+        return false;
+      }
+    }
+
+    logger.atFine().log(
+        "change %s matches because it has matching approvals from all human reviewers: %s",
+        cd.change().getChangeId(), humanReviewers);
+    return true;
+  }
+
+  private boolean matchZeroVotes(String label) {
+    Matcher m = PATTERN_LABEL.matcher(label);
+    if (!m.matches()) {
+      return false;
+    }
+
+    String op = m.group("op");
+    String value = m.group("value");
+
+    Optional<Integer> intValue = Optional.ofNullable(Ints.tryParse(value));
+
+    if (op.equals("=") && (intValue.isPresent() && intValue.get() == 0)) {
+      return true;
+    } else if (op.equals("<=")) {
+      if (intValue.isPresent() && intValue.get() >= 0) {
+        return true;
+      } else if (value.equals("MAX")) {
+        return true;
+      }
+      return false;
+    } else if (op.equals("<")) {
+      if (intValue.isPresent() && intValue.get() > 0) {
+        return true;
+      } else if (value.equals("MAX")) {
+        return true;
+      }
+    } else if (op.equals(">=")) {
+      if (intValue.isPresent() && intValue.get() <= 0) {
+        return true;
+      } else if (value.equals("MIN")) {
+        return true;
+      }
+      return false;
+    } else if (op.equals(">")) {
+      if (intValue.isPresent() && intValue.get() < 0) {
+        return true;
+      } else if (value.equals("MIN")) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index a5d86ce..9d40056 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -637,6 +637,205 @@
   }
 
   @Test
+  public void submitRequirement_wantCodeReviewMaxHumanReviewers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Want-Code-Review-From-All")
+            .setApplicabilityExpression(
+                Optional.of(
+                    SubmitRequirementExpression.create(
+                        "-label:Code-Review=MAX,users=human_reviewers")))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,users=human_reviewers"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Code-Review is unsatisfied because there is no Code-Review+2 approval.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is unsatisfied (since a review from reviewers is required but no
+    // reviewer is present on the change).
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Add some reviewers
+    TestAccount reviewer1 = accountCreator.create("reviewer1");
+    gApi.changes().id(changeId).addReviewer("reviewer1");
+    TestAccount reviewer2 = accountCreator.create("reviewer2");
+    gApi.changes().id(changeId).addReviewer("reviewer2");
+
+    // Code-Review is unsatisfied because there is no Code-Review+2 approval.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is unsatisfied since there are reviewers on the change that
+    // didn't approve it yet.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    requestScopeOperations.setApiUser(reviewer1.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Code-Review is satisfied because there is Code-Review+2 approval from reviewer1.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is unsatisfied since there is no approval from reviewer2.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    requestScopeOperations.setApiUser(reviewer2.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Code-Review is satisfied because there are Code-Review+2 approvals from reviewer1 and
+    // reviewer2.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is not applicable since there are approval from all reviewers
+    // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX,users=human_reviewers"
+    // satisfied.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_wantCodeReviewMaxHumanReviewers_enabledByFooter() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Want-Code-Review-From-All")
+            .setApplicabilityExpression(
+                Optional.of(
+                    SubmitRequirementExpression.create(
+                        "footer:\"WANT-LGTM: all\" -label:Code-Review=MAX,users=human_reviewers")))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,users=human_reviewers"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Add some reviewers
+    TestAccount reviewer1 = accountCreator.create("reviewer1");
+    gApi.changes().id(changeId).addReviewer("reviewer1");
+    TestAccount reviewer2 = accountCreator.create("reviewer2");
+    gApi.changes().id(changeId).addReviewer("reviewer2");
+
+    // Approve by one reviewer
+    requestScopeOperations.setApiUser(reviewer1.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Code-Review is satisfied because there is Code-Review+2 approval from reviewer1.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is not applicable since the commit message doesn't contain a
+    // "WANT-LGTM: all" footer.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+
+    // Amend the change to add a "WANT-LGTM: all" footer.
+    amendChange(
+        changeId,
+        PushOneCommit.SUBJECT
+            + "\n\nSome Description\n\nChange-Id: "
+            + changeId
+            + "\nWANT-LGTM: all\n",
+        PushOneCommit.FILE_NAME,
+        "content");
+
+    // Re-Approve by reviewer1.
+    requestScopeOperations.setApiUser(reviewer1.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Want-Code-Review-From-All is applicable since there is a "WANT-LGTM: all" footer and it is
+    // unsatisfied since there is no approval from reviewer2.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Approve by reviewer2.
+    requestScopeOperations.setApiUser(reviewer2.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Want-Code-Review-From-All is not applicable since there are approval from all reviewers
+    // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX,users=human_reviewers"
+    // satisfied.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
       throws Exception {
     configSubmitRequirement(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index 8643489..61a06a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -39,13 +40,19 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -467,6 +474,373 @@
     assertMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
   }
 
+  @Test
+  public void label_requireVoteFromHumanReviewers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    Account.Id owner = accountCreator.create("owner").id();
+    Account.Id reviewer1 = accountCreator.create("reviewer1").id();
+    Account.Id reviewer2 = accountCreator.create("reviewer2").id();
+    Account.Id reviewer3 = accountCreator.create("reviewer3").id();
+
+    Account.Id serviceUser = accountCreator.create("serviceUser").id();
+    gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).addMembers(serviceUser.toString());
+
+    Change.Id changeApprovedByAllReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeApprovedByAllReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(
+        project,
+        changeApprovedByAllReviewers,
+        ReviewInput.approve(),
+        reviewer1,
+        reviewer2,
+        reviewer3);
+
+    Change.Id changeApprovedBySomeReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeApprovedBySomeReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(project, changeApprovedBySomeReviewers, ReviewInput.approve(), reviewer1, reviewer2);
+
+    Change.Id changeRecommendedByAllReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeRecommendedByAllReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(
+        project,
+        changeRecommendedByAllReviewers,
+        ReviewInput.recommend(),
+        reviewer1,
+        reviewer2,
+        reviewer3);
+
+    Change.Id changeRecommendedBySomeReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeRecommendedBySomeReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(
+        project, changeRecommendedBySomeReviewers, ReviewInput.recommend(), reviewer1, reviewer2);
+
+    Change.Id changeNoVotesByReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeNoVotesByReviewers, reviewer1, reviewer2, reviewer3);
+
+    Change.Id changeWithoutReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // change without reviewers doesn't match
+    assertNotMatching("label:Code-Review=MAX,users=human_reviewers", changeWithoutReviewers);
+
+    // match changes where all reviewers have the same vote
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review=2,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review=1,users=human_reviewers",
+        ImmutableList.of(changeRecommendedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeApprovedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // match changes where no reviewer voted (same as "label:Code-Review=0")
+    assertRequirement(
+        "label:Code-Review=0,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewers),
+        ImmutableList.of(
+            changeApprovedByAllReviewers,
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers));
+
+    // match changes where all reviewers have a vote <=, >=, < or >
+    assertRequirement(
+        "label:Code-Review<=2,users=human_reviewers",
+        ImmutableList.of(
+            changeApprovedByAllReviewers,
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review<=1,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers),
+        ImmutableList.of(changeApprovedByAllReviewers));
+    assertRequirement(
+        "label:Code-Review>=1,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers, changeRecommendedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review<1,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewers),
+        ImmutableList.of(
+            changeApprovedByAllReviewers,
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers));
+    assertRequirement(
+        "label:Code-Review>1,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // match changes where all reviewers have any (non-zero) vote
+    assertRequirement(
+        "label:Code-Review=ANY,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers, changeRecommendedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // votes of the change owners are ignored (as the change owner is not considered as a reviewer)
+    addReviews(project, changeApprovedByAllReviewers, ReviewInput.dislike(), owner);
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // missing votes from service users are fine
+    addReviewers(project, changeApprovedByAllReviewers, serviceUser);
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // votes from service users are ignored
+    addReviews(project, changeApprovedByAllReviewers, ReviewInput.dislike(), serviceUser);
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // when reviewers by email are present changes do not match, unless the expected value is 0
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(md);
+      cfg.updateProject(
+          update ->
+              update.setBooleanConfig(
+                  BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      cfg.commit(md);
+    }
+    projectCache.evictAndReindex(project);
+    Change.Id changeRecommendedByAllReviewersWithReviewersByEmail =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(
+        project,
+        changeRecommendedByAllReviewersWithReviewersByEmail,
+        reviewer1,
+        reviewer2,
+        reviewer3);
+    addReviews(
+        project,
+        changeRecommendedByAllReviewersWithReviewersByEmail,
+        ReviewInput.recommend(),
+        reviewer1,
+        reviewer2,
+        reviewer3);
+    addReviewer(
+        project,
+        changeRecommendedByAllReviewersWithReviewersByEmail,
+        "email-without-account@example.com");
+    Change.Id changeNoVotesByReviewersWithReviewersByEmail =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(
+        project, changeNoVotesByReviewersWithReviewersByEmail, reviewer1, reviewer2, reviewer3);
+    addReviewer(
+        project, changeNoVotesByReviewersWithReviewersByEmail, "email-without-account@example.com");
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review=2,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review=ANY,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review=0,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of(changeRecommendedByAllReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<=2,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review<=0,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of(changeRecommendedByAllReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<=-1,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<2,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review<1,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of(changeRecommendedByAllReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<0,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review>=0,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review>=1,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review>-1,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review>0,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+
+    // cannot combine users=human_reviewers" with submit record status
+    assertError(
+        "label:Code-Review=ok,users=human_reviewers",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users=human_reviewers' argument in conjunction with a submit record label"
+            + " status");
+
+    // cannot combine "users" arg with a "user" arg
+    assertError(
+        "label:Code-Review=MAX,users=human_reviewers,user=reviewer1",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+
+    // cannot combine "users" arg with a "group" arg
+    assertError(
+        "label:Code-Review=MAX,users=human_reviewers,group=foo",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+
+    // cannot combine "users" arg with a positional arg
+    assertError(
+        "label:Code-Review=MAX,users=human_reviewers,reviewer1",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+    assertError(
+        "label:Code-Review=MAX,reviewer1,users=human_reviewers",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+
+    // label without "users=human_reviewers" still works
+    assertRequirement(
+        "label:Code-Review=MAX,user=reviewer1",
+        ImmutableList.of(changeApprovedByAllReviewers, changeApprovedBySomeReviewers),
+        ImmutableList.of(
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review=MAX,reviewer1",
+        ImmutableList.of(changeApprovedByAllReviewers, changeApprovedBySomeReviewers),
+        ImmutableList.of(
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+  }
+
+  private void addReviewers(Project.NameKey project, Change.Id changeId, Account.Id... reviewers)
+      throws Exception {
+    for (Account.Id reviewer : reviewers) {
+      addReviewer(project, changeId, reviewer.toString());
+    }
+  }
+
+  private void addReviewer(Project.NameKey project, Change.Id changeId, String reviewer)
+      throws Exception {
+    gApi.changes().id(project.get(), changeId.get()).addReviewer(reviewer);
+  }
+
+  private void addReviews(
+      Project.NameKey project, Change.Id changeId, ReviewInput reviewInput, Account.Id... reviewers)
+      throws Exception {
+    for (Account.Id reviewer : reviewers) {
+      requestScopeOperations.setApiUser(reviewer);
+      gApi.changes().id(project.get(), changeId.get()).current().review(reviewInput);
+    }
+  }
+
   private void approveAsUser(String changeId, Account.Id userId) throws Exception {
     requestScopeOperations.setApiUser(userId);
     approve(changeId);
@@ -540,13 +914,28 @@
     return threeWayMerger.getResultTreeId();
   }
 
+  private void assertRequirement(
+      String requirement,
+      ImmutableList<Change.Id> matchingChanges,
+      ImmutableList<Change.Id> nonMatchingChanges) {
+    for (Change.Id matchingChange : matchingChanges) {
+      assertMatching(requirement, matchingChange);
+    }
+
+    for (Change.Id nonMatchingChange : nonMatchingChanges) {
+      assertNotMatching(requirement, nonMatchingChange);
+    }
+  }
+
   private void assertMatching(String requirement, Change.Id change) {
-    assertThat(evaluate(requirement, change).status())
+    assertWithMessage("requirement \"%s\" doesn't match change %s", requirement, change)
+        .that(evaluate(requirement, change).status())
         .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
   }
 
   private void assertNotMatching(String requirement, Change.Id change) {
-    assertThat(evaluate(requirement, change).status())
+    assertWithMessage("requirement \"%s\" matches change %s", requirement, change)
+        .that(evaluate(requirement, change).status())
         .isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
   }
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a0aad96..1a546fa 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1552,6 +1552,12 @@
   }
 
   @Test
+  public void cannotUseUsersArgWithLabel() throws Exception {
+    assertFailingQuery(
+        "label:Code-Review=MAX,users=human_reviewers", "Cannot use the 'users' argument in search");
+  }
+
+  @Test
   public void byLabelMulti() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     repo = createAndOpenProject(project);