Check if change is approved by owners

Add simple verification if a change was approved by path owner.
Considering the following OWNERS file configuration:

  inherited: true
  matchers:
  - suffix: extension
    owners:
    - email

Ensure that change that modifies a file with 'extension' is submittable
only when score is given by user identified with 'email'.
When multiple owners are defined single approval is sufficient.
Note that change that modifies files with different extension is
verified for being submittable too.

Bug: Issue 15556
Change-Id: I8500a484d20c2a79f961f94d761b0322d60a4e65
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
index 49380a3..3a7334b 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
@@ -16,13 +16,25 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toSet;
 
+import com.google.common.base.Joiner;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Account.Id;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LegacySubmitRequirement;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -42,7 +54,9 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -58,12 +72,15 @@
   }
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final LegacySubmitRequirement SUBMIT_REQUIREMENT =
+      LegacySubmitRequirement.builder().setFallbackText("Owners").setType("owners").build();
 
   private final PluginSettings pluginSettings;
   private final ProjectCache projectCache;
   private final Accounts accounts;
   private final GitRepositoryManager repoManager;
   private final DiffOperations diffOperations;
+  private final ApprovalsUtil approvalsUtil;
 
   @Inject
   OwnersSubmitRequirement(
@@ -71,12 +88,14 @@
       ProjectCache projectCache,
       Accounts accounts,
       GitRepositoryManager repoManager,
-      DiffOperations diffOperations) {
+      DiffOperations diffOperations,
+      ApprovalsUtil approvalsUtil) {
     this.pluginSettings = pluginSettings;
     this.projectCache = projectCache;
     this.accounts = accounts;
     this.repoManager = repoManager;
     this.diffOperations = diffOperations;
+    this.approvalsUtil = approvalsUtil;
   }
 
   @Override
@@ -113,12 +132,35 @@
               getDiff(project, cd.currentPatchSet().commitId()),
               pluginSettings.expandGroups());
 
-      if (pathOwners.getFileOwners().isEmpty()) {
+      Map<String, Set<Id>> fileOwners = pathOwners.getFileOwners();
+      if (fileOwners.isEmpty()) {
         logger.atInfo().log("Change has no file owners defined. Skipping submit requirement.");
         return Optional.empty();
       }
 
-      logger.atInfo().log("TODO: check if change is approved.");
+      ChangeNotes notes = cd.notes();
+      requireNonNull(notes, "notes");
+      LabelTypes labelTypes = projectState.getLabelTypes(notes);
+      Account.Id uploader = notes.getCurrentPatchSet().uploader();
+      Map<Account.Id, List<PatchSetApproval>> approvalsByAccount =
+          Streams.stream(approvalsUtil.byPatchSet(notes, cd.currentPatchSet().id()))
+              .collect(Collectors.groupingBy(PatchSetApproval::accountId));
+
+      Set<String> missingApprovals =
+          fileOwners.entrySet().stream()
+              .filter(
+                  requiredApproval ->
+                      isApprovalMissing(requiredApproval, uploader, approvalsByAccount, labelTypes))
+              .map(Map.Entry::getKey)
+              .collect(toSet());
+
+      return Optional.of(
+          missingApprovals.isEmpty()
+              ? ok()
+              : notReady(
+                  String.format(
+                      "Missing approvals for path(s): [%s]",
+                      Joiner.on(", ").join(missingApprovals))));
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("TODO: handle exceptions");
       throw new IllegalStateException(
@@ -127,8 +169,57 @@
       logger.atSevere().withCause(e).log("TODO: handle exceptions");
       throw new IllegalStateException("Unable to get diff while evaluating owners requirement", e);
     }
+  }
 
-    return Optional.empty();
+  static boolean isApprovalMissing(
+      Map.Entry<String, Set<Account.Id>> requiredApproval,
+      Account.Id uploader,
+      Map<Account.Id, List<PatchSetApproval>> approvalsByAccount,
+      LabelTypes labelTypes) {
+    return requiredApproval.getValue().stream()
+        .noneMatch(
+            fileOwner -> isApprovedByOwner(fileOwner, uploader, approvalsByAccount, labelTypes));
+  }
+
+  static boolean isApprovedByOwner(
+      Account.Id fileOwner,
+      Account.Id uploader,
+      Map<Account.Id, List<PatchSetApproval>> approvalsByAccount,
+      LabelTypes labelTypes) {
+    return Optional.ofNullable(approvalsByAccount.get(fileOwner))
+        .map(
+            approvals ->
+                approvals.stream()
+                    .anyMatch(
+                        approval ->
+                            hasSufficientApproval(approval, labelTypes, fileOwner, uploader)))
+        .orElse(false);
+  }
+
+  static boolean hasSufficientApproval(
+      PatchSetApproval approval, LabelTypes labelTypes, Account.Id fileOwner, Account.Id uploader) {
+    return labelTypes
+        .byLabel(approval.labelId())
+        .map(label -> isLabelApproved(label, fileOwner, uploader, approval))
+        .orElse(false);
+  }
+
+  static boolean isLabelApproved(
+      LabelType label, Account.Id fileOwner, Account.Id uploader, PatchSetApproval approval) {
+    if (label.isIgnoreSelfApproval() && fileOwner.equals(uploader)) {
+      return false;
+    }
+
+    LabelFunction function = label.getFunction();
+    if (function.isMaxValueRequired()) {
+      return label.isMaxPositive(approval);
+    }
+
+    if (function.isBlock() && label.isMaxNegative(approval)) {
+      return false;
+    }
+
+    return approval.value() > label.getDefaultValue();
   }
 
   private Map<String, FileDiffOutput> getDiff(Project.NameKey project, ObjectId revision)
@@ -144,4 +235,23 @@
     // reviewed
     return diffOperations.listModifiedFilesAgainstParent(project, revision, 0);
   }
+
+  private static SubmitRecord notReady(String missingApprovals) {
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.status = SubmitRecord.Status.NOT_READY;
+    submitRecord.errorMessage = missingApprovals;
+    submitRecord.requirements = List.of(SUBMIT_REQUIREMENT);
+    SubmitRecord.Label label = new SubmitRecord.Label();
+    label.label = "Code-Review from owners";
+    label.status = SubmitRecord.Label.Status.NEED;
+    submitRecord.labels = List.of(label);
+    return submitRecord;
+  }
+
+  private static SubmitRecord ok() {
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.status = SubmitRecord.Status.OK;
+    submitRecord.requirements = List.of(SUBMIT_REQUIREMENT);
+    return submitRecord;
+  }
 }
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementIT.java
new file mode 100644
index 0000000..d557aed
--- /dev/null
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementIT.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2023 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.googlesource.gerrit.owners;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.config.GlobalPluginConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.util.stream.Stream;
+import org.junit.Test;
+
+@TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule")
+@UseLocalDisk
+public class OwnersSubmitRequirementIT extends LightweightPluginDaemonTest {
+  private static final LegacySubmitRequirementInfo NOT_READY =
+      new LegacySubmitRequirementInfo("NOT_READY", "Owners", "owners");
+  private static final LegacySubmitRequirementInfo READY =
+      new LegacySubmitRequirementInfo("OK", "Owners", "owners");
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  @GlobalPluginConfig(
+      pluginName = "owners",
+      name = "owners.enableSubmitRequirement",
+      value = "true")
+  public void shouldRequireAtLeastOneApprovalForMatchingPathFromOwner() throws Exception {
+    TestAccount admin2 = accountCreator.admin2();
+    TestAccount user1 = accountCreator.user1();
+    addOwnerFileWithMatchersToRoot(true, ".md", admin2, user1);
+
+    PushOneCommit.Result r = createChange("Add a file", "README.md", "foo");
+    ChangeApi changeApi = forChange(r);
+    ChangeInfo changeNotReady = changeApi.get();
+    assertThat(changeNotReady.submittable).isFalse();
+    assertThat(changeNotReady.requirements).containsExactly(NOT_READY);
+
+    changeApi.current().review(ReviewInput.approve());
+    ChangeInfo changeNotReadyAfterSelfApproval = changeApi.get();
+    assertThat(changeNotReadyAfterSelfApproval.submittable).isFalse();
+    assertThat(changeNotReadyAfterSelfApproval.requirements).containsExactly(NOT_READY);
+
+    requestScopeOperations.setApiUser(admin2.id());
+    forChange(r).current().review(ReviewInput.approve());
+    ChangeInfo changeReady = forChange(r).get();
+    assertThat(changeReady.submittable).isTrue();
+    assertThat(changeReady.requirements).containsExactly(READY);
+  }
+
+  @Test
+  @GlobalPluginConfig(
+      pluginName = "owners",
+      name = "owners.enableSubmitRequirement",
+      value = "true")
+  public void shouldNotRequireApprovalForNotMatchingPath() throws Exception {
+    TestAccount admin2 = accountCreator.admin2();
+    addOwnerFileWithMatchersToRoot(true, ".md", admin2);
+
+    PushOneCommit.Result r = createChange("Add a file", "README.txt", "foo");
+    ChangeApi changeApi = forChange(r);
+    ChangeInfo changeNotReady = changeApi.get();
+    assertThat(changeNotReady.submittable).isFalse();
+    assertThat(changeNotReady.requirements).isEmpty();
+
+    changeApi.current().review(ReviewInput.approve());
+    ChangeInfo changeReady = changeApi.get();
+    assertThat(changeReady.submittable).isTrue();
+    assertThat(changeReady.requirements).isEmpty();
+  }
+
+  private ChangeApi forChange(PushOneCommit.Result r) throws RestApiException {
+    return gApi.changes().id(r.getChangeId());
+  }
+
+  private void addOwnerFileWithMatchersToRoot(
+      boolean inherit, String extension, TestAccount... users) throws Exception {
+    // Add OWNERS file to root:
+    //
+    // inherited: true
+    // matchers:
+    // - suffix: extension
+    //   owners:
+    //   - u1.email()
+    //   - ...
+    //   - uN.email()
+    merge(
+        createChange(
+            testRepo,
+            "master",
+            "Add OWNER file",
+            "OWNERS",
+            String.format(
+                "inherited: %s\nmatchers:\n" + "- suffix: %s\n  owners:\n%s",
+                inherit,
+                extension,
+                Stream.of(users)
+                    .map(user -> String.format("   - %s\n", user.email()))
+                    .collect(joining())),
+            ""));
+  }
+}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementTest.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementTest.java
new file mode 100644
index 0000000..50f91b6
--- /dev/null
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementTest.java
@@ -0,0 +1,327 @@
+// Copyright (C) 2023 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.googlesource.gerrit.owners;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.hasSufficientApproval;
+import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.isApprovalMissing;
+import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.isApprovedByOwner;
+import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.isLabelApproved;
+import static org.mockito.Mockito.mock;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Test;
+
+public class OwnersSubmitRequirementTest {
+  private static String LABEL_ID = "foo";
+  private static int MAX_LABEL_VALUE = 1;
+
+  @Test
+  public void shouldApprovalBeMissingWhenSomeoneElseApproved() {
+    // given
+    Account.Id fileOwner = mock(Account.Id.class);
+    Account.Id uploader = mock(Account.Id.class);
+    LabelTypes labelTypes = maxNoBlockLabelFooTypes();
+    Map<Account.Id, List<PatchSetApproval>> uploaderApproval =
+        Map.of(uploader, List.of(approvedBy(uploader, LABEL_ID, MAX_LABEL_VALUE)));
+
+    // when
+    boolean isApprovalMissing =
+        isApprovalMissing(
+            Map.entry("path", Set.of(fileOwner)), uploader, uploaderApproval, labelTypes);
+
+    // then
+    assertThat(isApprovalMissing).isTrue();
+  }
+
+  @Test
+  public void shouldApprovalBeNotMissingWhenApprovedByFileOwner() {
+    // given
+    Account.Id fileOwner = mock(Account.Id.class);
+    Account.Id uploader = mock(Account.Id.class);
+    LabelTypes labelTypes = maxNoBlockLabelFooTypes();
+    Map<Account.Id, List<PatchSetApproval>> fileOwnerApproval =
+        Map.of(fileOwner, List.of(approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE)));
+
+    // when
+    boolean isApprovalMissing =
+        isApprovalMissing(
+            Map.entry("path", Set.of(fileOwner)), uploader, fileOwnerApproval, labelTypes);
+
+    // then
+    assertThat(isApprovalMissing).isFalse();
+  }
+
+  @Test
+  public void shouldApprovalBeNotMissingWhenApprovedByAtLeastOneOwner() {
+    // given
+    Account.Id fileOwnerA = mock(Account.Id.class);
+    Account.Id fileOwnerB = mock(Account.Id.class);
+    Account.Id uploader = mock(Account.Id.class);
+    LabelTypes labelTypes = maxNoBlockLabelFooTypes();
+    Map<Account.Id, List<PatchSetApproval>> fileOwnerApproval =
+        Map.of(fileOwnerA, List.of(approvedBy(fileOwnerA, LABEL_ID, MAX_LABEL_VALUE)));
+
+    // when
+    boolean isApprovalMissing =
+        isApprovalMissing(
+            Map.entry("path", Set.of(fileOwnerA, fileOwnerB)),
+            uploader,
+            fileOwnerApproval,
+            labelTypes);
+
+    // then
+    assertThat(isApprovalMissing).isFalse();
+  }
+
+  @Test
+  public void shouldNotBeApprovedByOwnerWhenSomeoneElseApproved() {
+    // given
+    Account.Id fileOwner = mock(Account.Id.class);
+    Account.Id uploader = mock(Account.Id.class);
+    LabelTypes labelTypes = maxNoBlockLabelFooTypes();
+    Map<Account.Id, List<PatchSetApproval>> uploaderApproval =
+        Map.of(uploader, List.of(approvedBy(uploader, LABEL_ID, MAX_LABEL_VALUE)));
+
+    // when
+    boolean approvedByOwner = isApprovedByOwner(fileOwner, fileOwner, uploaderApproval, labelTypes);
+
+    // then
+    assertThat(approvedByOwner).isFalse();
+  }
+
+  @Test
+  public void shouldNotBeApprovedWhenApprovalGivenForDifferentLabel() {
+    // given
+    Account.Id fileOwner = mock(Account.Id.class);
+    LabelTypes labelTypes =
+        new LabelTypes(
+            List.of(label().setName("bar").setFunction(LabelFunction.MAX_NO_BLOCK).build()));
+    Map<Account.Id, List<PatchSetApproval>> fileOwnerForDifferentLabelApproval =
+        Map.of(fileOwner, List.of(approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE)));
+
+    // when
+    boolean approvedByOwner =
+        isApprovedByOwner(fileOwner, fileOwner, fileOwnerForDifferentLabelApproval, labelTypes);
+
+    // then
+    assertThat(approvedByOwner).isFalse();
+  }
+
+  @Test
+  public void shouldBeApprovedByOwner() {
+    // given
+    Account.Id fileOwner = mock(Account.Id.class);
+    LabelTypes labelTypes = maxNoBlockLabelFooTypes();
+    Map<Account.Id, List<PatchSetApproval>> fileOwnerApproval =
+        Map.of(fileOwner, List.of(approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE)));
+
+    // when
+    boolean approvedByOwner =
+        isApprovedByOwner(fileOwner, fileOwner, fileOwnerApproval, labelTypes);
+
+    // then
+    assertThat(approvedByOwner).isTrue();
+  }
+
+  @Test
+  public void shouldHaveNotSufficientApprovalWhenLabelIsNotApproved() {
+    // given
+    LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build();
+    Account.Id fileOwner = mock(Account.Id.class);
+    LabelTypes labelTypes = new LabelTypes(List.of(maxValueRequired));
+
+    // when
+    boolean hasSufficientApproval =
+        hasSufficientApproval(approvedBy(fileOwner, LABEL_ID, 0), labelTypes, fileOwner, fileOwner);
+
+    // then
+    assertThat(hasSufficientApproval).isFalse();
+  }
+
+  @Test
+  public void shouldHaveNotSufficientApprovalWhenLabelDoesntMatch() {
+    // given
+    Account.Id fileOwner = mock(Account.Id.class);
+    LabelTypes labelTypes = new LabelTypes(Collections.emptyList());
+
+    // when
+    boolean hasSufficientApproval =
+        hasSufficientApproval(approvedBy(fileOwner, LABEL_ID, 0), labelTypes, fileOwner, fileOwner);
+
+    // then
+    assertThat(hasSufficientApproval).isFalse();
+  }
+
+  @Test
+  public void shouldHaveSufficientApprovalWhenLabelIsApproved() {
+    // given
+    LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build();
+    Account.Id fileOwner = mock(Account.Id.class);
+    LabelTypes labelTypes = new LabelTypes(List.of(maxValueRequired));
+
+    // when
+    boolean hasSufficientApproval =
+        hasSufficientApproval(
+            approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE), labelTypes, fileOwner, fileOwner);
+
+    // then
+    assertThat(hasSufficientApproval).isTrue();
+  }
+
+  @Test
+  public void labelShouldNotBeApprovedWhenSelfApprovalIsDisabledAndOwnerApproved() {
+    // given
+    LabelType ignoreSelfApproval = label().setIgnoreSelfApproval(true).build();
+    Account.Id fileOwner = mock(Account.Id.class);
+
+    // when
+    boolean approved =
+        isLabelApproved(
+            ignoreSelfApproval,
+            fileOwner,
+            fileOwner,
+            approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE));
+
+    // then
+    assertThat(approved).isFalse();
+  }
+
+  @Test
+  public void labelShouldNotBeApprovedWhenMaxValueIsRequiredButNotProvided() {
+    // given
+    LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build();
+    Account.Id fileOwner = mock(Account.Id.class);
+
+    // when
+    boolean approved =
+        isLabelApproved(maxValueRequired, fileOwner, fileOwner, approvedBy(fileOwner, LABEL_ID, 0));
+
+    // then
+    assertThat(approved).isFalse();
+  }
+
+  @Test
+  public void labelShouldBeApprovedWhenMaxValueIsRequiredAndProvided() {
+    // given
+    LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build();
+    Account.Id fileOwner = mock(Account.Id.class);
+
+    // when
+    boolean approved =
+        isLabelApproved(
+            maxValueRequired,
+            fileOwner,
+            fileOwner,
+            approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE));
+
+    // then
+    assertThat(approved).isTrue();
+  }
+
+  @Test
+  public void labelShouldNotBeApprovedWhenAnyValueWithBlockIsConfiguredAndMaxNegativeIsProvided() {
+    // given
+    LabelType anyWithBlock = label().setFunction(LabelFunction.ANY_WITH_BLOCK).build();
+    Account.Id fileOwner = mock(Account.Id.class);
+
+    // when
+    boolean approved =
+        isLabelApproved(anyWithBlock, fileOwner, fileOwner, approvedBy(fileOwner, LABEL_ID, -1));
+
+    // then
+    assertThat(approved).isFalse();
+  }
+
+  @Test
+  public void labelShouldBeApprovedWhenAnyValueWithBlockIsConfiguredAndPositiveValueIsProvided() {
+    // given
+    LabelType anyWithBlock =
+        label()
+            .setValues(
+                Arrays.asList(
+                    value(2, "Approved"),
+                    value(1, "OK"),
+                    value(0, "No score"),
+                    value(-1, "Blocked")))
+            .setFunction(LabelFunction.ANY_WITH_BLOCK)
+            .build();
+    Account.Id fileOwner = mock(Account.Id.class);
+
+    // when
+    boolean approved =
+        isLabelApproved(anyWithBlock, fileOwner, fileOwner, approvedBy(fileOwner, LABEL_ID, 1));
+
+    // then
+    assertThat(approved).isTrue();
+  }
+
+  @Test
+  public void labelShouldNotBeApprovedWhenAnyValueWithBlockIsConfiguredAndDefaultValueIsProvided() {
+    // given
+    LabelType anyWithBlock =
+        label()
+            .setValues(
+                Arrays.asList(
+                    value(2, "Approved"),
+                    value(1, "OK"),
+                    value(0, "No score"),
+                    value(-1, "Blocked")))
+            .setFunction(LabelFunction.ANY_WITH_BLOCK)
+            .build();
+    Account.Id fileOwner = mock(Account.Id.class);
+
+    // when
+    boolean approved =
+        isLabelApproved(anyWithBlock, fileOwner, fileOwner, approvedBy(fileOwner, LABEL_ID, 0));
+
+    // then
+    assertThat(approved).isFalse();
+  }
+
+  private static final LabelTypes maxNoBlockLabelFooTypes() {
+    LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build();
+    return new LabelTypes(List.of(maxValueRequired));
+  }
+
+  private static final LabelType.Builder label() {
+    return labelBuilder(
+        LABEL_ID, value(MAX_LABEL_VALUE, "Approved"), value(0, "No score"), value(-1, "Blocked"));
+  }
+
+  private static final PatchSetApproval approvedBy(Account.Id approving, String label, int value) {
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(mock(PatchSet.Id.class), approving, LabelId.create(label)))
+        .granted(Timestamp.from(Instant.now()))
+        .realAccountId(approving)
+        .value(value)
+        .build();
+  }
+}