Add a has:enabled_code-owners and has:approval_code-owners operands

Adding two operands to the 'has' predicate:
  * has:enabled_code-owners
  * has:approval_code-owners

The CodeOwnerApprovalPredicate wraps the existing CodeOwnerSubmitRule
logic. The predicate returns true only if the submit rule returns a
record with status = "OK". We will need this predicate to create query
expressions that depend on code owner approvals in submit requirements.

The two new operators work only for submit requirement expressions and
are not supported in search queries.

Bug: Google b/200665812
Change-Id: I391a0d7fd43528c87ff9dfc7ca981c7dc4d05407
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index 89c9554..1309f8f 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalHasOperand.CodeOwnerApprovalHasOperandModule;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerEnabledHasOperand.CodeOwnerEnabledHasOperandModule;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSubmitRule.CodeOwnerSubmitRuleModule;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfig;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot;
@@ -52,6 +54,8 @@
     }
 
     install(new CodeOwnerSubmitRuleModule());
+    install(new CodeOwnerApprovalHasOperandModule());
+    install(new CodeOwnerEnabledHasOperandModule());
 
     DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class);
     DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerApproval.class);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalHasOperand.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalHasOperand.java
new file mode 100644
index 0000000..ada921e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalHasOperand.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** A class contributing a "approval_code-owners" operand to the "has" predicate. */
+@Singleton
+public class CodeOwnerApprovalHasOperand implements ChangeHasOperandFactory {
+  static final String OPERAND = "approval";
+
+  public static class CodeOwnerApprovalHasOperandModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ChangeHasOperandFactory.class)
+          .annotatedWith(Exports.named(OPERAND))
+          .to(CodeOwnerApprovalHasOperand.class);
+    }
+  }
+
+  private final CodeOwnerApprovalPredicate codeOwnerApprovalPredicate;
+
+  @Inject
+  public CodeOwnerApprovalHasOperand(CodeOwnerApprovalPredicate codeOwnerApprovalPredicate) {
+    this.codeOwnerApprovalPredicate = codeOwnerApprovalPredicate;
+  }
+
+  @Override
+  public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+    return codeOwnerApprovalPredicate;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalPredicate.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalPredicate.java
new file mode 100644
index 0000000..7ca70b1
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalPredicate.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/**
+ * A predicate that checks if a given change has all necessary code owner approvals. Matches with
+ * changes that have a code owner approval or a code owner override. This predicate wraps the
+ * existing {@link CodeOwnerSubmitRule} to perform the logic.
+ *
+ * <p>We implement the {@link SubmitRequirementPredicate} interface to make this predicate available
+ * for submit requirement expressions. As a consequence, this predicate does not work with search
+ * queries. We do that since the computation of code owner approvals is expensive.
+ *
+ * <p>TODO(ghareeb): exclude code owner overrides from this predicate.
+ */
+@Singleton
+public class CodeOwnerApprovalPredicate extends SubmitRequirementPredicate {
+  private final CodeOwnerSubmitRule codeOwnerSubmitRule;
+
+  @Inject
+  public CodeOwnerApprovalPredicate(
+      @PluginName String pluginName, CodeOwnerSubmitRule codeOwnerSubmitRule) {
+    super("has", CodeOwnerApprovalHasOperand.OPERAND + "_" + pluginName);
+    this.codeOwnerSubmitRule = codeOwnerSubmitRule;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    Optional<SubmitRecord> submitRecord = codeOwnerSubmitRule.evaluate(changeData);
+    return submitRecord.isPresent() && submitRecord.get().status == SubmitRecord.Status.OK;
+  }
+
+  @Override
+  public int getCost() {
+    // Running the code owner approval predicate is expensive
+    return 10;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledHasOperand.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledHasOperand.java
new file mode 100644
index 0000000..e2c7ab9
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledHasOperand.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** A class contributing a "enabled_code-owners" operand to the "has" predicate. */
+@Singleton
+public class CodeOwnerEnabledHasOperand implements ChangeHasOperandFactory {
+  static final String OPERAND = "enabled";
+
+  public static class CodeOwnerEnabledHasOperandModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ChangeHasOperandFactory.class)
+          .annotatedWith(Exports.named(OPERAND))
+          .to(CodeOwnerEnabledHasOperand.class);
+    }
+  }
+
+  private final CodeOwnerEnabledPredicate codeOwnerEnabledPredicate;
+
+  @Inject
+  public CodeOwnerEnabledHasOperand(CodeOwnerEnabledPredicate codeOwnerEnabledPredicate) {
+    this.codeOwnerEnabledPredicate = codeOwnerEnabledPredicate;
+  }
+
+  @Override
+  public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+    return codeOwnerEnabledPredicate;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledPredicate.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledPredicate.java
new file mode 100644
index 0000000..6c9acec
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledPredicate.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * A predicate that returns true if the code-owners functionality is enabled for a given change.
+ *
+ * <p>We implement the {@link SubmitRequirementPredicate} interface to make this predicate available
+ * for submit requirement expressions. As a consequence, this predicate does not work with search
+ * queries. We do that since the computation of code owner approvals is expensive.
+ */
+@Singleton
+public class CodeOwnerEnabledPredicate extends SubmitRequirementPredicate {
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+
+  @Inject
+  public CodeOwnerEnabledPredicate(
+      @PluginName String pluginName, CodeOwnersPluginConfiguration codeOwnersPluginConfiguration) {
+    super("has", CodeOwnerEnabledHasOperand.OPERAND + "_" + pluginName);
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return !codeOwnersPluginConfiguration
+        .getProjectConfig(changeData.project())
+        .isDisabled(changeData.change().getDest().branch());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerHasOperandsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerHasOperandsIT.java
new file mode 100644
index 0000000..070cbb5
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerHasOperandsIT.java
@@ -0,0 +1,308 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.NOT_APPLICABLE;
+import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.SATISFIED;
+import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.UNSATISFIED;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalHasOperand;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@Sandboxed
+public class CodeOwnerHasOperandsIT extends AbstractCodeOwnersIT {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeQueryBuilder changeQueryBuilder;
+
+  private CodeOwnerApprovalHasOperand codeOwnerApprovalHasOperand;
+
+  @Before
+  public void setup() throws Exception {
+    codeOwnerApprovalHasOperand =
+        plugin.getSysInjector().getInstance(CodeOwnerApprovalHasOperand.class);
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Owner-Approval")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("has:enabled_code-owners"))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("has:approval_code-owners"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  @Test
+  public void hasApproval_notSupportedInSearchQueries() throws Exception {
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("has:approval_code-owners"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Operator 'has:approval_code-owners' cannot be used in queries");
+  }
+
+  @Test
+  public void hasEnabled_notSupportedInSearchQueries() throws Exception {
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("has:enabled_code-owners"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Operator 'has:enabled_code-owners' cannot be used in queries");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_satisfied() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    ChangeData changeData = createChange("Change Adding A File", path, "file content").getChange();
+    int changeId = changeData.change().getChangeId();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", UNSATISFIED);
+
+    // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeData.change().getKey().get());
+    changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", SATISFIED);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_unsatisfiedIfChangeIsClosed() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    ChangeData changeData = createChange("Change Adding A File", path, "file content").getChange();
+    int changeId = changeData.change().getChangeId();
+
+    // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeData.change().getKey().get());
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", SATISFIED);
+
+    // Approve and submit.
+    requestScopeOperations.setApiUser(admin.id());
+    approve(changeData.change().getKey().get());
+    gApi.changes().id(changeId).current().submit();
+    changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    // When the change is merged, submit requirement results are persisted in NoteDb. Later lookups
+    // return the persisted snapshot. Currently writing to NoteDb is disabled.
+    // TODO(ghareeb): update this check when we enable writing to NoteDb again.
+    assertNonExistentSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_internalServerError() throws Exception {
+    ChangeData changeData = createChange().getChange();
+
+    // Create a ChangeData without change notes to trigger an error.
+    // Set change and current patch set, so that this info can be included into the error message.
+    ChangeData changeDataWithoutChangeNotes = mock(ChangeData.class);
+    when(changeDataWithoutChangeNotes.change()).thenReturn(changeData.change());
+    when(changeDataWithoutChangeNotes.currentPatchSet()).thenReturn(changeData.currentPatchSet());
+
+    CodeOwnersInternalServerErrorException exception =
+        assertThrows(
+            CodeOwnersInternalServerErrorException.class,
+            () ->
+                codeOwnerApprovalHasOperand
+                    .create(changeQueryBuilder)
+                    .asMatchable()
+                    .match(changeDataWithoutChangeNotes));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to evaluate code owner statuses for patch set %d of change %d.",
+                changeData.change().currentPatchSetId().get(), changeData.change().getId().get()));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_ruleErrorForNonParsableCodeOwnerConfig() throws Exception {
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    ChangeData changeData = createChange().getChange();
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeData.change().getChangeId())
+            .get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    // Requirement is unsatisfied if a relevant code owner config file is not parseable and hence
+    // the submit rule cannot be evaluated.
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", UNSATISFIED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasEnabled_notMatchingWhenCodeOwnersIsDisabledForTheChange() throws Exception {
+    Change change =
+        createChange("Change Adding A File", "foo/bar.baz", "file content").getChange().change();
+    String changeId = change.getKey().get();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", NOT_APPLICABLE);
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+    changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", NOT_APPLICABLE);
+  }
+
+  private List<ChangeInfo> assertQuery(Object query, Change... changes) throws Exception {
+    QueryRequest queryRequest = newQuery(query);
+    Change.Id[] changeArray = Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new);
+    List<ChangeInfo> result = queryRequest.get();
+    Iterable<Change.Id> ids = ids(result);
+    assertWithMessage(format(queryRequest.getQuery(), ids, changeArray))
+        .that(ids)
+        .containsExactlyElementsIn(Arrays.asList(changeArray))
+        .inOrder();
+    return result;
+  }
+
+  private static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
+    return Streams.stream(changes).map(c -> Change.id(c._number)).collect(toList());
+  }
+
+  private QueryRequest newQuery(Object query) {
+    return gApi.changes().query(query.toString());
+  }
+
+  private void assertSubmitRequirement(
+      Collection<SubmitRequirementResultInfo> requirements, String name, Status status) {
+    for (SubmitRequirementResultInfo requirement : requirements) {
+      if (requirement.name.equals(name) && requirement.status == status) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            name,
+            status,
+            requirements.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private void assertNonExistentSubmitRequirement(
+      Collection<SubmitRequirementResultInfo> requirements, String name) {
+    for (SubmitRequirementResultInfo requirement : requirements) {
+      if (requirement.name.equals(name)) {
+        throw new AssertionError("Found a submit requirement with name " + name);
+      }
+    }
+  }
+
+  private String format(String query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
+      throws RestApiException {
+    return "query '"
+        + query
+        + "' with expected changes "
+        + format(Arrays.asList(expectedChanges))
+        + " and result "
+        + format(actualIds);
+  }
+
+  private String format(Iterable<Change.Id> changeIds) throws RestApiException {
+    Iterator<Change.Id> changeIdsItr = changeIds.iterator();
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    while (changeIdsItr.hasNext()) {
+      Change.Id id = changeIdsItr.next();
+      ChangeInfo c = gApi.changes().id(id.get()).get();
+      b.append("{")
+          .append(id)
+          .append(" (")
+          .append(c.changeId)
+          .append("), ")
+          .append("dest=")
+          .append(BranchNameKey.create(Project.nameKey(c.project), c.branch))
+          .append(", ")
+          .append("status=")
+          .append(c.status)
+          .append(", ")
+          .append("lastUpdated=")
+          .append(c.updated.getTime())
+          .append("}");
+      if (changeIdsItr.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+}
diff --git a/resources/Documentation/submit-requirement-operators.md b/resources/Documentation/submit-requirement-operators.md
new file mode 100644
index 0000000..1561b45
--- /dev/null
+++ b/resources/Documentation/submit-requirement-operators.md
@@ -0,0 +1,22 @@
+# Submit Requirement Operators
+
+The @PLUGIN@ plugin contributes the following operators. These operators can
+only be used in submit requirements expressions and cannot be used in search:
+
+ * **has:enabled_code-owners**
+
+   Matches with changes that have the code-owners functionality enabled. For
+   example, if code-owners is disabled for a specific branch, changes in this
+   branch will not be matched against this operator.
+
+ * **has:approval_code-owners**
+
+   Matches with changes that have all necessary code-owner approvals or a
+   code-owner override. This operator does not match with closed (merged)
+   changes.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/toc.md b/resources/Documentation/toc.md
index 701943f..da7b044 100644
--- a/resources/Documentation/toc.md
+++ b/resources/Documentation/toc.md
@@ -8,6 +8,7 @@
 * [Path Expressions](path-expressions.html)
 * [REST API](rest-api.html)
 * [Validation](validation.html)
+* [Submit Requirement Operators](submit-requirement-operators.html)
 
 ### Admin Guides