Allow 'message' predicate to take a regular expression as argument

This change adds RegEx support to the existing 'message' predicate.
The primary intention is for this to be used in submit requirements,
but since some index backends (e.g. Lucene) support regular expressions,
we add it as index predicate.

The matching is the same as for other index predicates
(e.g. RegexDirectoryPredicate) using brics.dk/automaton. This is a
notable difference to Prolog (which uses Java's built in Pattern).

Documentation was adapted.

Change-Id: Ic33c935feab18ce6e8a71b89b36a68c5461e8e58
Release-Notes: Added new message:regex search operator
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index ac62933..61a7365 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -334,6 +334,10 @@
 message:'MESSAGE'::
 +
 Changes that match 'MESSAGE' arbitrary string in the commit message body.
+By default full text matching is used, but regular expressions can be
+enabled by starting with `^`.
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
 
 [[comment]]
 comment:'TEXT'::
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c79b993..148270e 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -871,6 +871,10 @@
   public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
       fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
 
+  /** Commit message of the current patch set. */
+  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE_EXACT =
+      exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT).build(ChangeData::commitMessage);
+
   /** Summary or inline comment. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
       fullText(ChangeQueryBuilder.FIELD_COMMENT)
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9776584..0a06735 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -214,9 +214,14 @@
           .build();
 
   /** Added new field {@link ChangeField#FOOTER_NAME}. */
+  @Deprecated
   static final Schema<ChangeData> V76 =
       new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
 
+  /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
+  static final Schema<ChangeData> V77 =
+      new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 4491aa6..ae82bdb 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -181,6 +181,7 @@
   public static final String FIELD_MERGEABLE = "mergeable2";
   public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
+  public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTOF = "parentof";
@@ -1106,7 +1107,11 @@
   }
 
   @Operator
-  public Predicate<ChangeData> message(String text) {
+  public Predicate<ChangeData> message(String text) throws QueryParseException {
+    if (text.startsWith("^")) {
+      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      return new RegexMessagePredicate(text);
+    }
     return ChangePredicates.message(text);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
new file mode 100644
index 0000000..a2b9ad8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
@@ -0,0 +1,52 @@
+// 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 com.google.gerrit.server.index.change.ChangeField;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexMessagePredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexMessagePredicate(String re) throws QueryParseException {
+    super(ChangeField.COMMIT_MESSAGE_EXACT, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    try {
+      this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", re), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return pattern.run(cd.commitMessage());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index c851e64..109f299 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1000,6 +1000,29 @@
   }
 
   @Test
+  public void byMessageRegEx() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nDO NOT SUBMIT").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    RevCommit commit4 =
+        repo.parseBody(repo.commit().message("Title\n\nfoobar do NOT submit").create());
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+
+    assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
+    assertQuery("message:\"^aaaa(c)*c.*\"", change2);
+    assertQuery("message:\"^.*DO NOT SUBMIT.*\"", change3);
+    assertQuery(
+        "message:\"^.*(D|d)(O|o) (N|n)(O|o)(T|t) (S|s)(U|u)(B|b)(M|m)(I|i)(T|t).*\"",
+        change4,
+        change3);
+  }
+
+  @Test
   public void fullTextWithNumbers() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());