LogServlet: support grep for log messages

Add a `grep` query parameter to the log servlet. This lets Gitiles filter
log results by commit message using JGit's message filter.

The new filter is combined with the existing log filters, so it can be
used together with author or committer filtering.

Add coverage that log grep stays in the log page navigation URD.

Issue: 376381593
Change-Id: Ib1ad5fe6cfdb29ce3528aedad983126d1f7590ed
diff --git a/Documentation/api-reference.md b/Documentation/api-reference.md
index 99ee0ee..5f0ae79 100644
--- a/Documentation/api-reference.md
+++ b/Documentation/api-reference.md
@@ -27,6 +27,7 @@
 Shows the commit log.
 Use the parameter `n=<number>` to limit the number of commits returned.
 For paging use the start parameter `s=<next_cursor>`.
+Use the parameter `grep=<pattern>` to filter commits by commit message.
 The `next` key in the JSON provides a cursor for the next page. Use it with `s=<next_cursor>`.
 The final page will have no `next` key.
 Every page except for the first will have a `previous` cursor to page backwards.
diff --git a/java/com/google/gitiles/LogServlet.java b/java/com/google/gitiles/LogServlet.java
index 368fd0b..9e7b801 100644
--- a/java/com/google/gitiles/LogServlet.java
+++ b/java/com/google/gitiles/LogServlet.java
@@ -56,6 +56,7 @@
 import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.revwalk.filter.AndRevFilter;
+import org.eclipse.jgit.revwalk.filter.MessageRevFilter;
 import org.eclipse.jgit.revwalk.filter.RevFilter;
 import org.eclipse.jgit.treewalk.filter.ChangedPathTreeFilter;
 import org.eclipse.jgit.util.StringUtils;
@@ -73,6 +74,7 @@
   private static final String TOPO_ORDER_PARAM = "topo-order";
   private static final String REVERSE_PARAM = "reverse";
   private static final String FIRST_PARENT_PARAM = "first-parent";
+  private static final String GREP_PARAM = "grep";
 
   private static final int DEFAULT_LIMIT = 100;
   private static final int MAX_LIMIT = 10000;
@@ -260,7 +262,7 @@
   }
 
   private static void setRevFilter(RevWalk walk, GitilesView view) {
-    List<RevFilter> filters = new ArrayList<>(3);
+    List<RevFilter> filters = new ArrayList<>(4);
     if (isTrue(view, "no-merges")) {
       filters.add(RevFilter.NO_MERGES);
     }
@@ -275,6 +277,11 @@
       filters.add(IdentRevFilter.committer(committer));
     }
 
+    String grep = Iterables.getFirst(view.getParameters().get(GREP_PARAM), null);
+    if (!Strings.isNullOrEmpty(grep)) {
+      filters.add(MessageRevFilter.create(grep));
+    }
+
     if (filters.size() > 1) {
       walk.setRevFilter(AndRevFilter.create(filters));
     } else if (filters.size() == 1) {
diff --git a/javatests/com/google/gitiles/LogServletTest.java b/javatests/com/google/gitiles/LogServletTest.java
index cc0d619..2916f3a 100644
--- a/javatests/com/google/gitiles/LogServletTest.java
+++ b/javatests/com/google/gitiles/LogServletTest.java
@@ -157,6 +157,59 @@
     verifyJsonCommit(response.log.get(0), c1);
   }
 
+  @Test
+  public void grepFilterLog() throws Exception {
+    RevCommit c1 =
+        repo.branch("master")
+            .commit()
+            .message("Add search support\n\nBody contains release-notes.")
+            .add("foo", "one")
+            .create();
+    repo.branch("master")
+        .commit()
+        .message("Other change\n\nNo match here.")
+        .add("foo", "two")
+        .create();
+
+    Log response = buildJson(LOG, "/repo/+log/master", "grep=release-notes");
+    assertThat(response.log).hasSize(1);
+    verifyJsonCommit(response.log.get(0), c1);
+  }
+
+  @Test
+  public void grepFilterCombinesWithAuthorFilter() throws Exception {
+    PersonIdent matchingAuthor = new PersonIdent("Matching Author", "matching.author@example.com");
+    PersonIdent otherAuthor = new PersonIdent("Other Author", "other.author@example.com");
+
+    RevCommit c1 =
+        repo.branch("master")
+            .commit()
+            .author(matchingAuthor)
+            .message("Search target\n")
+            .add("foo", "one")
+            .create();
+    repo.branch("master")
+        .commit()
+        .author(otherAuthor)
+        .message("Search target\n")
+        .add("foo", "two")
+        .create();
+    repo.branch("master")
+        .commit()
+        .author(matchingAuthor)
+        .message("Other change\n")
+        .add("foo", "three")
+        .create();
+
+    Log response =
+        buildJson(
+            LOG,
+            "/repo/+log/master",
+            "author=" + matchingAuthor.getEmailAddress() + "&grep=target");
+    assertThat(response.log).hasSize(1);
+    verifyJsonCommit(response.log.get(0), c1);
+  }
+
   private void verifyJsonCommit(Commit jsonCommit, RevCommit commit) throws Exception {
     repo.getRevWalk().parseBody(commit);
     GitilesAccess access = new TestGitilesAccess(repo.getRepository()).forRequest(null);
@@ -237,6 +290,35 @@
   }
 
   @Test
+  public void verifyNextButtonPreservesGrep() throws Exception {
+    repo.branch(MAIN).commit().message("target base").add("foo", "contents").create();
+    RevCommit grandParent =
+        repo.branch(MAIN).commit().message("target first").add("foo", "contents").create();
+    RevCommit parent =
+        repo.branch(MAIN)
+            .commit()
+            .parent(grandParent)
+            .message("target second")
+            .add("foo", "contents")
+            .create();
+    RevCommit main =
+        repo.branch(MAIN)
+            .commit()
+            .parent(parent)
+            .message("target third")
+            .add("foo", "contents")
+            .create();
+
+    String path =
+        "/repo/+log/" + grandParent.toObjectId().getName() + ".." + main.toObjectId().getName();
+    FakeHttpServletResponse res = buildResponse(path, "format=html&n=1&grep=target", SC_OK);
+
+    assertThat(res.getActualBodyString()).contains("<a class=\"LogNav-next\"");
+    assertThat(res.getActualBodyString()).contains("&amp;grep=target");
+    assertThat(res.getActualBodyString()).contains("&amp;s=" + parent.toObjectId().getName());
+  }
+
+  @Test
   public void prettyDefaultUsesDefaultCssClass() throws Exception {
     RevCommit parent = repo.branch(MAIN).commit().add("foo", "contents").create();
     RevCommit main = repo.branch(MAIN).commit().parent(parent).create();