Implement `hasfooter` predicate to search for changes with a footer ignoring it's value

In the context of submit requirements, there is a use case to match
changes that have a footer with a certain name, irrespective of
the footer's value. The most prominent example is `hasfooter:Bug`.

In the Gerrit project we are thinking of requiring changes to have a
`Release-Notes` footer to make releasing easier. That requirement
would also not need to care about the footer's value.

This seems universally useful also for change search, so we
implement it as change predicate instead of submit requirement
predicate.

Change-Id: I34a35fdab6fdc4e0f09aa0bec71af4fcdfbb2bbb
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index f314e46..7e8a7e0 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -419,6 +419,12 @@
 current patch set. 'FOOTER' can be specified verbatim ('<key>: <value>', must
 be quoted) or as '<key>=<value>'. The matching is done case-insensitive.
 
+[[hasfooter-operator]]
+hasfooter:'FOOTERNAME'::
++
+Matches any change that has a commit message with a footer where the footer
+name is equal to 'FOOTERNAME'.The matching is done case-sensitive.
+
 [[star]]
 star:'LABEL'::
 +
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 3d5dca8..baac95b 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -271,6 +271,14 @@
         .collect(toSet());
   }
 
+  /** Footers from the commit message of the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FOOTER_NAME =
+      exact(ChangeQueryBuilder.FIELD_FOOTER_NAME).buildRepeatable(ChangeField::getFootersNames);
+
+  public static Set<String> getFootersNames(ChangeData cd) {
+    return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
+  }
+
   /** Folders that are touched by the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
       exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9ff806d..9776584 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -205,6 +205,7 @@
    * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
    * allow easier search for topics.
    */
+  @Deprecated
   static final Schema<ChangeData> V75 =
       new Schema.Builder<ChangeData>()
           .add(V74)
@@ -212,6 +213,10 @@
           .add(ChangeField.PREFIX_TOPIC)
           .build();
 
+  /** Added new field {@link ChangeField#FOOTER_NAME}. */
+  static final Schema<ChangeData> V76 =
+      new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).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/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 355f9de..ce17b31 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -270,6 +270,14 @@
   }
 
   /**
+   * Returns a predicate that matches changes with the provided {@code footer} name in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> hasFooter(String footerName) {
+    return new ChangeIndexPredicate(ChangeField.FOOTER_NAME, footerName);
+  }
+
+  /**
    * Returns a predicate that matches changes that modified files in the provided {@code directory}.
    */
   public static Predicate<ChangeData> directory(String directory) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index c828d4d..4491aa6 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -163,6 +163,7 @@
   public static final String FIELD_EXTENSION = "extension";
   public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
   public static final String FIELD_FOOTER = "footer";
+  public static final String FIELD_FOOTER_NAME = "footernames";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -955,6 +956,12 @@
   }
 
   @Operator
+  public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
+    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    return ChangePredicates.hasFooter(footerName);
+  }
+
+  @Operator
   public Predicate<ChangeData> dir(String directory) {
     return directory(directory);
   }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index e916147..c851e64 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1675,6 +1675,27 @@
   }
 
   @Test
+  public void byFooterName() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    // create a changes with lines that look like footers, but which are not
+    RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
+    insert(repo, newChangeForCommit(repo, commit6));
+
+    // matching by 'key=value' works
+    assertQuery("hasfooter:foo", change1);
+
+    // case matters
+    assertQuery("hasfooter:BaR", change2);
+    assertQuery("hasfooter:Bar");
+  }
+
+  @Test
   public void byDirectory() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));