Add 'has:hashtag' search operator

Add a has:hashtag operand that is true for changes with at least one
hashtag. The operator reuses the existing prefix hashtag field. An
empty prefix matches every term in that field, and only changes that
carry at least one hashtag have any term there.

Release-Notes: Add has:hashtag search operator
Change-Id: I8b4b0b2f836c2f5b9668c9be7cb60b137a90e0e2
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 8197a96..5d40e2d 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -498,6 +498,10 @@
 +
 True if the change has attention by the current user.
 
+has:hashtag::
++
+True if the change has at least one hashtag.
+
 
 [[is]]
 [[is-starred]]
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index d5f9c5b..fdaa061 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -249,6 +249,11 @@
         ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
+  /** Returns a predicate that matches changes that are tagged with at least one hashtag. */
+  public static Predicate<ChangeData> hasHashtag() {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_HASHTAG, "");
+  }
+
   /**
    * Returns a predicate that matches changes in the provided {@code hashtag}. Used with prefixes
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 704f8ed..6311c65 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -719,6 +719,11 @@
       return new IsUnresolvedPredicate();
     }
 
+    if ("hashtag".equalsIgnoreCase(value)) {
+      checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "has:hashtag");
+      return ChangePredicates.hasHashtag();
+    }
+
     // for plugins the value will be operandName_pluginName
     List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 0f09496..8d1e3dd 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -2871,6 +2871,23 @@
   }
 
   @Test
+  public void byHasHashtag() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_HASHTAG)).isTrue();
+
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
+
+    addHashtags(change1, "foo");
+    addHashtags(change2, "foo", "bar");
+
+    assertQuery("has:hashtag", change2, change1);
+    assertQuery("-has:hashtag", change3);
+  }
+
+  @Test
   public void byHashtagFullText() throws Exception {
     assume().that(getSchema().hasField(ChangeField.FUZZY_HASHTAG)).isTrue();
     ImmutableList<Change> changes = setUpHashtagChanges();
diff --git a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
index 852e549..8b9676e 100644
--- a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
@@ -61,6 +61,7 @@
   'has:attention',
   'has:draft',
   'has:edit',
+  'has:hashtag',
   'has:star',
   'has:unresolved',
   'hasfooter:',