Suggest owners that are reviewers even if annotated with LAST_RESORT_SUGGESTION

So far code owners that are annotated with LAST_RESORT_SUGGESTION were
only suggested if the suggestion result would be empty otherwise. This
meant that these code owners were also not suggested after they have
been manually added as a reviewer. Due to this the UI couldn't show
files that were owned by these users with a check mark (= covered by a
reviewer) as the UI only considers code owners that are part of the
suggestion. To fix this issue, code owners that are annotated with
LAST_RESORT_SUGGESTION are now also included in the suggestion result if
they are already a reviewer. This means if a code owner is already a
reviewer the LAST_RESORT_SUGGESTION annotation is ignored.

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I6b6a0a7d776e166d655076019bbc3596a713517f
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
index 0b3d3fc..3ed9a51 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
@@ -152,7 +152,7 @@
             filteredCodeOwners.stream()
                 .filter(
                     filterOutCodeOwnersThatAreAnnotatedWithLastResortSuggestion(
-                        annotations, debugLogs))
+                        rsrc, annotations, debugLogs))
                 .collect(toImmutableList());
     if (filteredCodeOwnersWithoutCodeOwnersThatAreAnnotatedWithLastResortSuggestion.isEmpty()) {
       // The result would be empty, hence return code owners that are annotated with
@@ -177,12 +177,27 @@
   }
 
   private Predicate<CodeOwner> filterOutCodeOwnersThatAreAnnotatedWithLastResortSuggestion(
+      CodeOwnersInChangeCollection.PathResource rsrc,
       ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
       ImmutableList.Builder<String> debugLogs) {
     return codeOwner -> {
       boolean lastResortSuggestion =
           annotations.containsEntry(
               codeOwner, CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION);
+
+      // If the code owner is already a reviewer, the code owner should always be suggested, even
+      // if annotated with LAST_RESORT_SUGGESTION_ANNOTATION.
+      if (isReviewer(rsrc, codeOwner)) {
+        if (lastResortSuggestion) {
+          debugLogs.add(
+              String.format(
+                  "ignoring %s annotation for %s because this code owner is a reviewer",
+                  CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key(), codeOwner));
+        }
+
+        // Returning true from the Predicate here means that the code owner should be kept.
+        return true;
+      }
       if (!lastResortSuggestion) {
         // Returning true from the Predicate here means that the code owner should be kept.
         return true;
@@ -196,6 +211,14 @@
     };
   }
 
+  private boolean isReviewer(CodeOwnersInChangeCollection.PathResource rsrc, CodeOwner codeOwner) {
+    return rsrc.getRevisionResource()
+        .getNotes()
+        .getReviewers()
+        .byState(ReviewerStateInternal.REVIEWER)
+        .contains(codeOwner.accountId());
+  }
+
   private Predicate<CodeOwner> filterOutServiceUsers(ImmutableList.Builder<String> debugLogs) {
     return codeOwner -> {
       if (!isServiceUser(codeOwner)) {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
index 1f09ce1..a4d69f4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
@@ -401,6 +401,51 @@
   }
 
   @Test
+  public void
+      codeOwnersWithLastResortSuggestionAnnotation_annotationIgnoredIfCodeOwnerIsAlreadyAReviewer()
+          throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .addAnnotation(user.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user2.email())
+                .addAnnotation(
+                    user2.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: admin is suggested, user and user2 get filtered out due to the
+    // LAST_RESORT_SUGGESTION annotation
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+
+    // Add user as a reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Expectation: user is being suggested now despite of the LAST_RESORT_SUGGESTION annotation
+    // because user is a reviewer, admin is still suggested and user2 is still filtered out due to
+    // the LAST_RESORT_SUGGESTION annotation
+    codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
   public void codeOwnersWithLastResortSuggestionAnnotation_annotationSetForAllUsersWildcard()
       throws Exception {
     skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
@@ -507,6 +552,52 @@
   }
 
   @Test
+  public void
+      perFileCodeOwnersWithLastResortSuggestionAnnotation_annotationIgnoredIfCodeOwnerIsAlreadyAReviewer()
+          throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .addAnnotation(user.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user2.email())
+                .addAnnotation(
+                    user2.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: admin is suggested, user and user2 get filtered out due to the
+    // LAST_RESORT_SUGGESTION annotation
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+
+    // Add user as a reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Expectation: user is being suggested now despite of the LAST_RESORT_SUGGESTION annotation
+    // because user is a reviewer, admin is still suggested and user2 is still filtered out due to
+    // the LAST_RESORT_SUGGESTION annotation
+    codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
   public void perFileCodeOwnersWithLastResortSuggestionAnnotation_annotationSetForAllUsersWildcard()
       throws Exception {
     skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
diff --git a/resources/Documentation/backend-find-owners.md b/resources/Documentation/backend-find-owners.md
index 21125a9..6680d40 100644
--- a/resources/Documentation/backend-find-owners.md
+++ b/resources/Documentation/backend-find-owners.md
@@ -318,11 +318,12 @@
 * `LAST_RESORT_SUGGESTION`:
   Code owners with this annotation are omitted when [suggesting code
   owners](rest-api.html#list-code-owners-for-path-in-change), except if dropping
-  these code owners would make the suggestion result empty. If code ownership is
-  assigned to the same code owner through multiple relevant access grants in the
-  same code owner config file or in other relevant code owner config files the
-  code owner gets omitted from the suggestion if it has the
-  `LAST_RESORT_SUGGESTION` set on any of the access grants.
+  these code owners would make the suggestion result empty or if these code
+  owners are already reviewers of the change. If code ownership is assigned to
+  the same code owner through multiple relevant access grants in the same code
+  owner config file or in other relevant code owner config files the code owner
+  gets omitted from the suggestion if it has the `LAST_RESORT_SUGGESTION` set on
+  any of the access grants.
 
 Unknown annotations are silently ignored.
 
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 0227a5f..f08a932 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -700,7 +700,8 @@
 * the change owner (since the change owner cannot be added as reviewer)
 * code owners that are annotated with
   [LAST_RESORT_SUGGESTION](backend-find-owners.html#lastResortSuggestion),
-  except if dropping these code owners would make the suggestion result empty
+  except if dropping these code owners would make the suggestion result empty or
+  if these code owners are already reviewers of the change
 
 In addition, by default the change number is used as seed if none was specified.
 This way the sort order on a change is always the same for files that have the