Return code-owners~skip-validation option in GetValidationOptions response

Return the code-owners~skip-validation option only if the code owners
functionality is enabled for the change and if the user is allowed to
skip the validation.

Bug: Google b/279897514
Change-Id: Ic50cc6ec1b3ab50649c3966ce8e865f438b80983
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
index 2443ef5..b11bd61 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
@@ -18,8 +18,10 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -30,21 +32,23 @@
 @Singleton
 public class SkipCodeOwnerConfigValidationPushOption implements PluginPushOption {
   public static final String NAME = "skip-validation";
-
-  private static final String DESCRIPTION = "skips the code owner config validation";
+  public static final String DESCRIPTION = "skips the code owner config validation";
 
   private final String pluginName;
   private final PermissionBackend permissionBackend;
   private final SkipCodeOwnerConfigValidationCapability skipCodeOwnerConfigValidationCapability;
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
 
   @Inject
   SkipCodeOwnerConfigValidationPushOption(
       @PluginName String pluginName,
       PermissionBackend permissionBackend,
-      SkipCodeOwnerConfigValidationCapability skipCodeOwnerConfigValidationCapability) {
+      SkipCodeOwnerConfigValidationCapability skipCodeOwnerConfigValidationCapability,
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration) {
     this.pluginName = pluginName;
     this.permissionBackend = permissionBackend;
     this.skipCodeOwnerConfigValidationCapability = skipCodeOwnerConfigValidationCapability;
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
   }
 
   @Override
@@ -57,6 +61,14 @@
     return DESCRIPTION;
   }
 
+  @Override
+  public boolean isOptionEnabled(Change change) {
+    return !codeOwnersPluginConfiguration
+            .getProjectConfig(change.getProject())
+            .isDisabled(change.getDest().branch())
+        && canSkipCodeOwnerConfigValidation();
+  }
+
   /**
    * Whether the code owner config validation should be skipped.
    *
@@ -86,7 +98,7 @@
 
     String value = values.get(0);
     if (Boolean.parseBoolean(value) || value.isEmpty()) {
-      canSkipCodeOwnerConfigValidation();
+      checkCanSkipCodeOwnerConfigValidation();
       return true;
     }
 
@@ -98,7 +110,7 @@
     throw new InvalidValueException(values);
   }
 
-  private void canSkipCodeOwnerConfigValidation() throws AuthException {
+  private void checkCanSkipCodeOwnerConfigValidation() throws AuthException {
     try {
       permissionBackend
           .currentUser()
@@ -112,6 +124,20 @@
     }
   }
 
+  private boolean canSkipCodeOwnerConfigValidation() {
+    try {
+      return permissionBackend
+          .currentUser()
+          .test(skipCodeOwnerConfigValidationCapability.getPermission());
+    } catch (PermissionBackendException e) {
+      throw newInternalServerError(
+          String.format(
+              "Failed to check %s~%s capability",
+              pluginName, SkipCodeOwnerConfigValidationCapability.ID),
+          e);
+    }
+  }
+
   public class InvalidValueException extends Exception {
     private static final long serialVersionUID = 1L;
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/SkipCodeOwnerConfigValidationPushOptionIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/SkipCodeOwnerConfigValidationPushOptionIT.java
new file mode 100644
index 0000000..3abee10
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/SkipCodeOwnerConfigValidationPushOptionIT.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2025 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.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.ValidationOptionInfo;
+import com.google.gerrit.extensions.common.ValidationOptionInfos;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.validation.SkipCodeOwnerConfigValidationCapability;
+import com.google.gerrit.plugins.codeowners.validation.SkipCodeOwnerConfigValidationPushOption;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class SkipCodeOwnerConfigValidationPushOptionIT extends AbstractCodeOwnersIT {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void getCodeOwnersSkipOptionAsAdmin() throws Exception {
+    // Use the admin user that has the SkipCodeOwnerConfigValidationCapability global capability
+    // implicitly assigned.
+    requestScopeOperations.setApiUser(admin.id());
+
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ValidationOptionInfos validationOptionsInfos =
+        gApi.changes().id(project.get(), changeId.get()).getValidationOptions();
+    assertThat(validationOptionsInfos.validationOptions)
+        .isEqualTo(
+            ImmutableList.of(
+                new ValidationOptionInfo(
+                    "code-owners~" + SkipCodeOwnerConfigValidationPushOption.NAME,
+                    SkipCodeOwnerConfigValidationPushOption.DESCRIPTION)));
+  }
+
+  @Test
+  public void getCodeOwnersSkipOptionAsUserWithTheSkipCodeOwnerConfigValidationCapability()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability("code-owners-" + SkipCodeOwnerConfigValidationCapability.ID)
+                .group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ValidationOptionInfos validationOptionsInfos =
+        gApi.changes().id(project.get(), changeId.get()).getValidationOptions();
+    assertThat(validationOptionsInfos.validationOptions)
+        .isEqualTo(
+            ImmutableList.of(
+                new ValidationOptionInfo(
+                    "code-owners~" + SkipCodeOwnerConfigValidationPushOption.NAME,
+                    SkipCodeOwnerConfigValidationPushOption.DESCRIPTION)));
+  }
+
+  @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/heads/master")
+  @Test
+  public void codeOwnersSkipOptionIsOmittedIfUserCannotSkipTheCodeOwnersValidation()
+      throws Exception {
+    // Use non-admin user that doesn't have the SkipCodeOwnerConfigValidationCapability global
+    // capability.
+    requestScopeOperations.setApiUser(user.id());
+
+    Change.Id changeId = changeOperations.newChange().project(project).branch("master").create();
+    ValidationOptionInfos validationOptionsInfos =
+        gApi.changes().id(project.get(), changeId.get()).getValidationOptions();
+    assertThat(validationOptionsInfos.validationOptions).isEmpty();
+  }
+
+  @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
+  @Test
+  public void codeOwnersSkipOptionIsOmittedIfCodeOwnersFunctionalityIsDisabledForProject()
+      throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ValidationOptionInfos validationOptionsInfos =
+        gApi.changes().id(project.get(), changeId.get()).getValidationOptions();
+    assertThat(validationOptionsInfos.validationOptions).isEmpty();
+  }
+
+  @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/heads/master")
+  @Test
+  public void codeOwnersSkipOptionIsOmittedIfCodeOwnersFunctionalityIsDisabledForBranch()
+      throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).branch("master").create();
+    ValidationOptionInfos validationOptionsInfos =
+        gApi.changes().id(project.get(), changeId.get()).getValidationOptions();
+    assertThat(validationOptionsInfos.validationOptions).isEmpty();
+  }
+}