Allow filtering log by author

This allows specifying an ?author=<author> URL parameter on the log and
index page to only show commits by author names which contain <author>.
Equivalent to the --author command of git log, except for now it does
not support regular expressions.

Change-Id: I01e11f12ed5b4245856e1bc4574bfc8452bcdc43
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/AuthorRevFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/AuthorRevFilter.java
new file mode 100644
index 0000000..52c4cc1
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/AuthorRevFilter.java
@@ -0,0 +1,61 @@
+// Copyright 2014 Google Inc. All Rights Reserved.
+//
+// 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.gitiles;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.StopWalkException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.revwalk.filter.RevFilter;
+
+import java.io.IOException;
+
+/**
+ * A {@link RevFilter} which only includes {@link RevCommit}s by an author pattern.
+ *
+ * Mostly equivalent to {@code git log --author}.
+ */
+public class AuthorRevFilter extends RevFilter {
+  private final String authorPattern;
+
+  public AuthorRevFilter(String authorPattern) {
+    this.authorPattern = authorPattern;
+  }
+
+  @Override
+  public boolean include(RevWalk walker, RevCommit commit) throws StopWalkException,
+      MissingObjectException, IncorrectObjectTypeException, IOException {
+    return matchesPerson(commit.getAuthorIdent());
+  }
+
+  /** @return whether the given person matches the author filter. */
+  @VisibleForTesting
+  boolean matchesPerson(PersonIdent person) {
+    // Equivalent to --fixed-strings, to avoid pathological performance of Java
+    // regex matching.
+    // TODO(kalman): Find/use a port of re2.
+    return person.getName().contains(authorPattern)
+        || person.getEmailAddress().contains(authorPattern);
+  }
+
+  @Override
+  public RevFilter clone() {
+    return this;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
index 5b147b3..1593988 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
@@ -211,6 +211,10 @@
           PathFilterGroup.createFromStrings(view.getPathPart()),
           TreeFilter.ANY_DIFF));
     }
+    String author = Iterables.getFirst(view.getParameters().get("author"), null);
+    if (author != null) {
+      walk.setRevFilter(new AuthorRevFilter(author));
+    }
     return walk;
   }
 
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/AuthorRevFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/AuthorRevFilterTest.java
new file mode 100644
index 0000000..42a4c21
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/AuthorRevFilterTest.java
@@ -0,0 +1,88 @@
+// Copyright 2014 Google Inc. All Rights Reserved.
+//
+// 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.gitiles;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/**
+ * Tests for {@link AuthorRevFilter}.
+ *
+ * Unfortunately it's not easy to test the Filter using real {@link RevCommit}s
+ * because {@link TestRepository} hard-codes its author as "J. Author". The next
+ * best thing is to test a {@link PersonIdent}, those are easy to construct.
+ * TODO(dborowitz): Fix TestRepository to allow this.
+ */
+public class AuthorRevFilterTest {
+  @Test
+  public void matchesName() throws Exception {
+    AuthorRevFilter filter = new AuthorRevFilter("eSt");
+    assertTrue(filter.matchesPerson(new PersonIdent("eSt", "null@google.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("eStablish", "null@google.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("teSt", "null@google.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("teSting", "null@google.com")));
+  }
+
+  @Test
+  public void caseSensitiveName() throws Exception {
+    AuthorRevFilter filter = new AuthorRevFilter("eSt");
+    assertFalse(filter.matchesPerson(new PersonIdent("est", "null@google.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("Establish", "null@google.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("tESt", "null@google.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("tesTing", "null@google.com")));
+  }
+
+  @Test
+  public void matchesEmailLocalPart() throws Exception {
+    AuthorRevFilter filter = new AuthorRevFilter("eSt");
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "eSt@google.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "eStablish@google.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "teSt@google.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "teSting@google.com")));
+  }
+
+  @Test
+  public void caseSensitiveEmailLocalPart() throws Exception {
+    AuthorRevFilter filter = new AuthorRevFilter("eSt");
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "est@google.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "Establish@google.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "tESt@google.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "tesTing@google.com")));
+  }
+
+  @Test
+  public void matchesEmailDomain() throws Exception {
+    // git log --author matches the email domain as well as the enail name.
+    AuthorRevFilter filter = new AuthorRevFilter("eSt");
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "null@eSt.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "null@eStablish.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "null@teSt.com")));
+    assertTrue(filter.matchesPerson(new PersonIdent("null", "null@teSting.com")));
+  }
+
+  @Test
+  public void caseSensitiveEmailDomain() throws Exception {
+    AuthorRevFilter filter = new AuthorRevFilter("eSt");
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "null@est.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "null@Establish.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "null@tESt.com")));
+    assertFalse(filter.matchesPerson(new PersonIdent("null", "null@tesTing.com")));
+  }
+}