Add a handler for /+describe similar to "git describe"

Options parallel options to "git describe", e.g.
$ curl http://gitiles/repo/+describe/deadbeef?contains&all&format=JSON
master~3

Only JSON and TEXT formats are supported, there is no HTML template.
For now, only --contains is supported, using JGit's NameRevCommand
(since "git describe --contains" calls "git name-rev" internally).

Change-Id: Ia71cb546645f93eb19eac4db69103835e46ae678
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
index a2bc65c..e47e3df 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
@@ -258,6 +258,27 @@
     return res.getWriter();
   }
 
+  /**
+   * Render an error as plain text.
+   *
+   * @param req in-progress request.
+   * @param res in-progress response.
+   * @param statusCode HTTP status code.
+   * @param message full message text.
+   *
+   * @throws IOException
+   */
+  protected void renderTextError(HttpServletRequest req, HttpServletResponse res, int statusCode,
+      String message) throws IOException {
+    res.setStatus(statusCode);
+    res.setContentType(TEXT.getMimeType());
+    res.setCharacterEncoding("UTF-8");
+    setCacheHeaders(req, res);
+    PrintWriter out = res.getWriter();
+    out.write(message);
+    out.close();
+  }
+
   protected void setCacheHeaders(HttpServletRequest req, HttpServletResponse res) {
     setNotCacheable(res);
   }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DescribeServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/DescribeServlet.java
new file mode 100644
index 0000000..7b49f63
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DescribeServlet.java
@@ -0,0 +1,149 @@
+// Copyright 2013 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 javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.NameRevCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.gson.reflect.TypeToken;
+
+/** Serves an API result describing an object. */
+public class DescribeServlet extends BaseServlet {
+  private static final long serialVersionUID = 1L;
+
+  private static final String ALL_PARAM = "all";
+  private static final String CONTAINS_PARAM = "contains";
+  private static final String TAGS_PARAM = "tags";
+
+  private static boolean getBooleanParam(GitilesView view, String name) {
+    List<String> values = view.getParameters().get(name);
+    return !values.isEmpty()
+        && (values.get(0).equals("") || values.get(0).equals("1"));
+  }
+
+  protected DescribeServlet() {
+    super(null);
+  }
+
+  @Override
+  protected void doGetText(HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    String name = describe(ServletUtils.getRepository(req), ViewFilter.getView(req), req, res);
+    if (name == null) {
+      return;
+    }
+    PrintWriter out = startRenderText(req, res);
+    out.write(RefServlet.sanitizeRefForText(name));
+    out.close();
+  }
+
+  @Override
+  protected void doGetJson(HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    String name = describe(ServletUtils.getRepository(req), ViewFilter.getView(req), req, res);
+    if (name == null) {
+      return;
+    }
+    renderJson(req, res,
+        ImmutableMap.of(ViewFilter.getView(req).getPathPart(), name),
+        new TypeToken<Map<String, String>>() {}.getType());
+  }
+
+  private ObjectId resolve(Repository repo, GitilesView view, HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
+    String rev = view.getPathPart();
+    try {
+      return repo.resolve(rev);
+    } catch (RevisionSyntaxException e) {
+      renderTextError(req, res, SC_BAD_REQUEST,
+          "Invalid revision syntax: " + RefServlet.sanitizeRefForText(rev));
+      return null;
+    } catch (AmbiguousObjectException e) {
+      renderTextError(req, res, SC_BAD_REQUEST, String.format(
+            "Ambiguous short SHA-1 %s (%s)",
+            e.getAbbreviatedObjectId(), Joiner.on(", ").join(e.getCandidates())));
+      return null;
+    }
+  }
+
+  private String describe(Repository repo, GitilesView view, HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
+    if (!getBooleanParam(view, CONTAINS_PARAM)) {
+      res.setStatus(SC_BAD_REQUEST);
+      return null;
+    }
+    ObjectId id = resolve(repo, view, req, res);
+    if (id == null) {
+      return null;
+    }
+    NameRevCommand cmd = nameRevCommand(id, req, res);
+    if (cmd == null) {
+      return null;
+    }
+    String name;
+    try {
+      name = cmd.call().get(id);
+    } catch (GitAPIException e) {
+      throw new IOException(e);
+    }
+    if (name == null) {
+      res.setStatus(SC_NOT_FOUND);
+      return null;
+    }
+    return name;
+  }
+
+  private NameRevCommand nameRevCommand(ObjectId id, HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
+    Repository repo = ServletUtils.getRepository(req);
+    GitilesView view = ViewFilter.getView(req);
+    NameRevCommand cmd = new Git(repo).nameRev();
+    boolean all = getBooleanParam(view, ALL_PARAM);
+    boolean tags = getBooleanParam(view, TAGS_PARAM);
+    if (all && tags) {
+      renderTextError(req, res, SC_BAD_REQUEST, "Cannot specify both \"all\" and \"tags\"");
+      return null;
+    }
+    if (all) {
+      cmd.addPrefix(Constants.R_REFS);
+    } else if (tags) {
+      cmd.addPrefix(Constants.R_TAGS);
+    } else {
+      cmd.addAnnotatedTags();
+    }
+    cmd.add(id);
+    return cmd;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
index a69835c..1955fff 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -240,6 +240,8 @@
         return new DiffServlet(renderer, linkifier());
       case LOG:
         return new LogServlet(renderer, linkifier());
+      case DESCRIBE:
+        return new DescribeServlet();
       default:
         throw new IllegalArgumentException("Invalid view type: " + view);
     }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
index 16f5a54..885074d 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -55,7 +55,8 @@
     REVISION,
     PATH,
     DIFF,
-    LOG;
+    LOG,
+    DESCRIBE;
   }
 
   /** Exception thrown when building a view that is invalid. */
@@ -98,6 +99,7 @@
         case REVISION:
           revision = other.revision;
           // Fallthrough.
+        case DESCRIBE:
         case REFS:
         case REPOSITORY_INDEX:
           repositoryName = other.repositoryName;
@@ -151,6 +153,7 @@
         case HOST_INDEX:
         case REPOSITORY_INDEX:
         case REFS:
+        case DESCRIBE:
           throw new IllegalStateException(String.format("cannot set revision on %s view", type));
         default:
           this.revision = checkNotNull(revision);
@@ -204,6 +207,7 @@
         case DIFF:
           this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path));
           return this;
+        case DESCRIBE:
         case REFS:
         case LOG:
           this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
@@ -260,6 +264,9 @@
         case REFS:
           checkRefs();
           break;
+        case DESCRIBE:
+          checkDescribe();
+          break;
         case REVISION:
           checkRevision();
           break;
@@ -301,6 +308,10 @@
       checkRepositoryIndex();
     }
 
+    private void checkDescribe() {
+      checkRepositoryIndex();
+    }
+
     private void checkRevision() {
       checkView(revision != Revision.NULL, "missing revision on %s view", type);
       checkRepositoryIndex();
@@ -332,6 +343,10 @@
     return new Builder(Type.REFS);
   }
 
+  public static Builder describe() {
+    return new Builder(Type.DESCRIBE);
+  }
+
   public static Builder revision() {
     return new Builder(Type.REVISION);
   }
@@ -459,6 +474,9 @@
       case REFS:
         url.append(repositoryName).append("/+refs");
         break;
+      case DESCRIBE:
+        url.append(repositoryName).append("/+describe");
+        break;
       case REVISION:
         url.append(repositoryName).append("/+/").append(revision.getName());
         break;
@@ -522,6 +540,8 @@
    *     auto-diving into one-entry subtrees.
    */
   public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) {
+    checkArgument(type != Type.DESCRIBE,
+        "breadcrumbs for DESCRIBE view not supported");
     checkArgument(type != Type.REFS || Strings.isNullOrEmpty(path),
         "breadcrumbs for REFS view with path not supported");
     checkArgument(hasSingleTree == null || type == Type.PATH,
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
index ba52d37..8d259a8 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
@@ -167,7 +167,7 @@
     return result;
   }
 
-  private static String sanitizeRefForText(String refName) {
+  static String sanitizeRefForText(String refName) {
     return refName.replace("&", "&amp;")
         .replace("<", "&lt;")
         .replace(">", "&gt;");
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
index 6dd167c..33b53a1 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -39,6 +39,7 @@
   private static final String VIEW_ATTIRBUTE = ViewFilter.class.getName() + "/View";
 
   private static final String CMD_AUTO = "+";
+  private static final String CMD_DESCRIBE = "+describe";
   private static final String CMD_DIFF = "+diff";
   private static final String CMD_LOG = "+log";
   private static final String CMD_REFS = "+refs";
@@ -98,14 +99,17 @@
     String repoName = trimLeadingSlash(getRegexGroup(req, 1));
     String command = getRegexGroup(req, 2);
     String path = getRegexGroup(req, 3);
+    boolean emptyPath = (path.isEmpty() || path.equals("/"));
 
     // Non-path cases.
     if (repoName.isEmpty()) {
       return GitilesView.hostIndex();
     } else if (command.equals(CMD_REFS)) {
       return GitilesView.refs().setRepositoryName(repoName).setPathPart(path);
-    } else if (command.equals(CMD_LOG) && (path.isEmpty() || path.equals("/"))) {
+    } else if (command.equals(CMD_LOG) && emptyPath) {
       return GitilesView.log().setRepositoryName(repoName);
+    } else if (command.equals(CMD_DESCRIBE) && !emptyPath) {
+      return GitilesView.describe().setRepositoryName(repoName).setPathPart(path);
     } else if (command.isEmpty()) {
       return GitilesView.repositoryIndex().setRepositoryName(repoName);
     } else if (path.isEmpty()) {
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
index 49d8405..fb28533 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -165,6 +165,22 @@
         view.getBreadcrumbs());
   }
 
+  public void testDescribe() throws Exception {
+    GitilesView view = GitilesView.describe()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .setPathPart("deadbeef")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.DESCRIBE, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals("deadbeef", view.getPathPart());
+    assertTrue(HOST.getParameters().isEmpty());
+  }
+
   public void testNoPathComponents() throws Exception {
     ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     GitilesView view = GitilesView.path()
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
index fdb291c..3f0b727 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -135,6 +135,27 @@
     assertEquals("heads/master", view.getPathPart());
   }
 
+  public void testDescribe() throws Exception {
+    GitilesView view;
+
+    assertNull(getView("/repo/+describe"));
+    assertNull(getView("/repo/+describe/"));
+
+    view = getView("/repo/+describe/deadbeef");
+    assertEquals(Type.DESCRIBE, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("deadbeef", view.getPathPart());
+
+    view = getView("/repo/+describe/refs/heads/master~3^~2");
+    assertEquals(Type.DESCRIBE, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("refs/heads/master~3^~2", view.getPathPart());
+  }
+
   public void testBranches() throws Exception {
     RevCommit master = repo.branch("refs/heads/master").commit().create();
     RevCommit stable = repo.branch("refs/heads/stable").commit().create();