Add a new 'authoremail' submit requirement predicate

The new predicate returns true if the change author's email address
matches a specific regular expression pattern. The new predicate is
exactly the same as the commit_author prolog predicate (see [1]) which
accepts regular expressions.

Note: we could've used the already available 'author' search operator
but that operator matches with both name/email addresses, allowing an
adversary to set their name in a way that might match a submit
requirement rule. For this reason we implemented this as a new submit
requirement predicate that only matches with email addresses.

[1] https://gerrit-review.googlesource.com/Documentation/prolog-change-facts.html

Release-Notes: Add new authoremail:<email_pattern> submit requirement operator
Change-Id: I28a5e8f7d5494f051aaa26b2c2b7c5eaceae19fb
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 465cb53..1782159 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -143,6 +143,14 @@
 [[submit_requirements_operators]]
 === Submit Requirements Operators
 
+[[operator_authoremail]]
+authoremail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change author's email address matches a
+specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
 [[operator_is_true]]
 is:true::
 +
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
new file mode 100644
index 0000000..22891bc
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2022 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.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+/**
+ * A submit requirement predicate that matches with changes having the author email's address
+ * matching a specific regular expression pattern.
+ */
+public class RegexAuthorEmailPredicate extends SubmitRequirementPredicate {
+  protected final RunAutomaton authorEmailPattern;
+
+  public RegexAuthorEmailPredicate(String pattern) throws QueryParseException {
+    super("authoremail", pattern);
+
+    if (pattern.startsWith("^")) {
+      pattern = pattern.substring(1);
+    }
+
+    if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+      pattern = pattern.substring(0, pattern.length() - 1);
+    }
+
+    try {
+      this.authorEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return authorEmailPattern.run(cd.getAuthor().getEmailAddress());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 1750be0..54e0dd6 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -54,4 +54,9 @@
     }
     return super.is(value);
   }
+
+  @Operator
+  public Predicate<ChangeData> authoremail(String who) throws QueryParseException {
+    return new RegexAuthorEmailPredicate(who);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0f27d93..a16efb9 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -26,7 +26,9 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
@@ -37,6 +39,7 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -60,6 +63,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private ChangeData changeData;
   private String changeId;
@@ -447,6 +451,52 @@
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
   }
 
+  @Test
+  public void byAuthorEmail() throws Exception {
+    TestAccount user2 =
+        accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+    requestScopeOperations.setApiUser(user2.id());
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+    ChangeData cd =
+        changeQueryProvider
+            .get()
+            .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+            .get(0);
+
+    // Match by email works
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^.*@example\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^user@.*\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+
+    // Match by name does not work
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^Foo$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^User$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  private void checkSubmitRequirementResult(
+      ChangeData cd, String submittabilityExpr, SubmitRequirementResult.Status expectedStatus) {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            submittabilityExpr,
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, cd);
+    assertThat(result.status()).isEqualTo(expectedStatus);
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }