Add basic blame support

Blame is rendered as a two-cell table, with blame information in the
left cell and the normal blob <pre> in the right.

Currently implemented in a blocking fashion, rendering the entire
blame before sending to the client. Additionally, no state is shared
between requests, since JGit's blame API does not support blaming of a
sequence of history in a way that would make this easier.

Change-Id: I639f1795a9ba6cc0c9677740e41ac34d82c8eb6e
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java
index 53f2213..c73f7c0 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java
@@ -14,7 +14,34 @@
 
 package com.google.gitiles;
 
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.blame.BlameGenerator;
+import org.eclipse.jgit.blame.BlameResult;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 
 /** Serves an HTML page with blame data for a commit. */
 public class BlameServlet extends BaseServlet {
@@ -23,4 +50,134 @@
   public BlameServlet(Config cfg, Renderer renderer) {
     super(cfg, renderer);
   }
+
+  @Override
+  protected void doGetHtml(HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
+
+    RevWalk rw = new RevWalk(repo);
+    try {
+      ObjectId blobId = resolveBlob(view, rw);
+      if (blobId == null) {
+        res.setStatus(SC_NOT_FOUND);
+        return;
+      }
+
+      String title = "Blame - " + view.getPathPart();
+      Map<String, ?> blobData = new BlobSoyData(rw, view).toSoyData(view.getPathPart(), blobId);
+      if (blobData.get("data") != null) {
+        BlameResult blame = doBlame(repo, view);
+        if (blame == null) {
+          res.setStatus(SC_NOT_FOUND);
+          return;
+        }
+        GitDateFormatter df = new GitDateFormatter(Format.DEFAULT);
+        int lineCount = blame.getResultContents().size();
+        blame.discardResultContents();
+        renderHtml(req, res, "gitiles.blameDetail", ImmutableMap.of(
+            "title", title,
+            "breadcrumbs", view.getBreadcrumbs(),
+            "data", blobData,
+            "regions", toRegionData(view, rw.getObjectReader(), blame, lineCount, df)));
+      } else {
+        renderHtml(req, res, "gitiles.blameDetail", ImmutableMap.of(
+            "title", title,
+            "breadcrumbs", view.getBreadcrumbs(),
+            "data", blobData));
+      }
+    } finally {
+      rw.release();
+    }
+  }
+
+  private static ObjectId resolveBlob(GitilesView view, RevWalk rw) throws IOException {
+    try {
+      TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), view.getPathPart(),
+          rw.parseTree(view.getRevision().getId()));
+      if ((tw.getRawMode(0) & FileMode.TYPE_FILE) == 0) {
+        return null;
+      }
+      return tw.getObjectId(0);
+    } catch (IncorrectObjectTypeException e) {
+      return null;
+    }
+  }
+
+  private static BlameResult doBlame(Repository repo, GitilesView view) throws IOException {
+    BlameGenerator gen = new BlameGenerator(repo, view.getPathPart());
+    BlameResult blame;
+    try {
+      // TODO: works on annotated tag?
+      gen.push(null, view.getRevision().getId());
+      blame = gen.computeBlameResult();
+    } finally {
+      gen.release();
+    }
+    return blame;
+  }
+
+  private List<Map<String, ?>> toRegionData(GitilesView view, ObjectReader reader,
+      BlameResult blame, int lineCount, GitDateFormatter df) throws IOException {
+    List<Region> regions = Lists.newArrayList();
+    for (int i = 0; i < lineCount; i++) {
+      if (regions.isEmpty() || !regions.get(regions.size() - 1).growFrom(blame, i)) {
+        regions.add(new Region(blame, i));
+      }
+    }
+
+    Map<ObjectId, String> abbrevShas = Maps.newHashMap();
+    List<Map<String, ?>> result = Lists.newArrayListWithCapacity(regions.size());
+    for (Region r : regions) {
+      result.add(r.toSoyData(view, reader, abbrevShas, df));
+    }
+    return result;
+  }
+
+  private class Region {
+    private final String sourcePath;
+    private final RevCommit sourceCommit;
+    private int count;
+
+    private Region(BlameResult blame, int start) {
+      this.sourcePath = blame.getSourcePath(start);
+      this.sourceCommit = blame.getSourceCommit(start);
+      this.count = 1;
+    }
+
+    private boolean growFrom(BlameResult blame, int i) {
+      // Don't compare line numbers, so we collapse regions from the same source
+      // but with deleted lines into one.
+      if (Objects.equal(blame.getSourcePath(i), sourcePath)
+          && Objects.equal(blame.getSourceCommit(i), sourceCommit)) {
+        count++;
+        return true;
+      } else {
+        return false;
+      }
+    }
+
+    private Map<String, ?> toSoyData(GitilesView view, ObjectReader reader,
+        Map<ObjectId, String> abbrevShas, GitDateFormatter df) throws IOException {
+      if (sourceCommit == null) {
+        // JGit bug may fail to blame some regions. We should fix this
+        // upstream, but handle it for now.
+        return ImmutableMap.of("count", count);
+      }
+      String abbrevSha = abbrevShas.get(sourceCommit);
+      if (abbrevSha == null) {
+        abbrevSha = reader.abbreviate(sourceCommit).name();
+        abbrevShas.put(sourceCommit, abbrevSha);
+      }
+      return ImmutableMap.of(
+          "abbrevSha", abbrevSha,
+          "url", GitilesView.blame().copyFrom(view)
+              .setRevision(sourceCommit.name())
+              .setPathPart(sourcePath)
+              .toUrl(),
+          "author", CommitSoyData.toSoyData(sourceCommit.getAuthorIdent(), df),
+          "count", count);
+    }
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
index 6b57cbc..5c8d65f 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
@@ -77,6 +77,7 @@
     }
     if (path != null && view.getRevision().getPeeledType() == OBJ_COMMIT) {
       data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
+      data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl());
     }
     return data;
   }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
index aa12869..1ab26c4 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -36,6 +36,7 @@
 /** Renderer for Soy templates used by Gitiles. */
 public abstract class Renderer {
   private static final List<String> SOY_FILENAMES = ImmutableList.of(
+      "BlameDetail.soy",
       "Common.soy",
       "DiffDetail.soy",
       "HostIndex.soy",
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
index 0f1f67b..9d37f51 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
@@ -333,6 +333,43 @@
 }
 
 
+/* Styles for the blame detail template. */
+
+#blame {
+  margin: 0;
+  padding: 0;
+}
+#blame td {
+  padding: 0;
+}
+#regions {
+  padding-top: 2px;
+  padding-right: 2px;
+  padding-bottom: 5px;
+  padding-left: 2px;
+
+  /* Matching pre.git-blob below. */
+  font-family: monospace;
+  font-size: 8pt;
+  border-bottom: #ddd solid 1px; /* BORDER */
+}
+#regions ul {
+  list-style-type: none;
+  margin-top: 0;
+  margin-bottom: 0;
+  padding-left: 0;
+}
+#blame .time, #blame .sha1 {
+  /* Smaller than SHORTLOG_SMALL_FONT_SIZE to match pre. */
+  font-size: 8pt;
+}
+#blame .author {
+  padding-left: 0px;
+}
+#blame pre.git-blob {
+  border-top: 0;
+}
+
 /* Override some styles from the default prettify.css. */
 
 /* Line numbers on all lines. */
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy
new file mode 100644
index 0000000..109a442
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy
@@ -0,0 +1,83 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page showing blame info for a file.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param data blob data, matching the params for .blobBox.
+ * @param? regions for non-binary files, list of blame regions with the
+ *     following keys:
+ *       abbrevSha: abbreviated SHA-1 of revision for this line; if missing,
+ *           assume blame info is missing.
+ *       url: URL for detail about the revision
+ *       author: author information with at least "name" and "relativeTime"
+ *           keys.
+ *       relativeTime: relative time of the revision
+ *       count: line count
+ */
+{template .blameDetail}
+{if $regions}
+  {call .header}
+    {param title: $title /}
+    {param repositoryName: $repositoryName /}
+    {param menuEntries: $menuEntries /}
+    {param breadcrumbs: $breadcrumbs /}
+    {param css: [gitiles.PRETTIFY_CSS_URL] /}
+    {param js: [gitiles.PRETTIFY_JS_URL] /}
+    {param onLoad: 'prettyPrint()' /}
+  {/call}
+
+  <table id="blame">
+    <tr>
+      <td>
+        <pre id="regions">
+          <ul>
+            {foreach $region in $regions}
+              <li>
+                {if $region.abbrevSha}
+                  <a href="{$region.url}">
+                    <span class="sha1">{$region.abbrevSha}</span>
+                    {sp}<span class="author">{$region.author.name}</span>
+                    {sp}<span class="time">- {$region.author.relativeTime}</span>
+                  </a>
+                {else}
+                  &nbsp;
+                {/if}
+              </li>
+              {for $i in range($region.count - 1)}
+                <li>&nbsp;</li>
+              {/for}
+            {/foreach}
+          </ul>
+        </pre>
+      </td>
+      <td>
+        {call .blobBox data="$data" /}
+      </td>
+    </tr>
+  </table>
+
+  </div>
+{else}
+  {call .header data="all" /}
+  {call .blobDetail data="all" /}
+{/if}
+
+{call .footer /}
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
index 07dfc87..78ea913 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
@@ -209,6 +209,7 @@
  *
  * @param sha SHA of this file's blob.
  * @param? logUrl optional URL to a log for this file.
+ * @param? blameUrl optional URL to a blame for this file.
  * @param data file data (may be empty), or null for a binary file.
  * @param? lang prettyprint language extension for text file.
  * @param? size for binary files only, size in bytes.
@@ -217,23 +218,35 @@
 <div class="sha1">
   {msg desc="SHA-1 for the file's blob"}blob: {$sha}{/msg}
   {if $logUrl}{sp}[<a href="{$logUrl}">{msg desc="history for a file"}file history{/msg}</a>]{/if}
+  {if $blameUrl}{sp}[<a href="{$blameUrl}">{msg desc="blame for a file"}blame{/msg}</a>]{/if}
 </div>
 
-{if $data != null}
-  {if $data}
-    {if $lang != null}
-      <pre class="git-blob prettyprint linenums lang-{$lang}">{$data}</pre>
+{call .blobBox data="all" /}
+{/template}
+
+/**
+ * Preformatted box containing blob contents.
+ *
+ * @param data file data (may be empty), or null for a binary file.
+ * @param? lang prettyprint language extension for text file.
+ * @param? size for binary files only, size in bytes.
+ */
+{template .blobBox}
+  {if $data != null}
+    {if $data}
+      {if $lang != null}
+        <pre class="git-blob prettyprint linenums lang-{$lang}">{$data}</pre>
+      {else}
+        <pre class="git-blob">{$data}</pre>
+      {/if}
     {else}
-      <pre class="git-blob">{$data}</pre>
+      <div class="file-empty">Empty file</div>
     {/if}
   {else}
-    <div class="file-empty">Empty file</div>
+    <div class="file-binary">
+      {msg desc="size of binary file in bytes"}{$size}-byte binary file{/msg}
+    </div>
   {/if}
-{else}
-  <div class="file-binary">
-    {msg desc="size of binary file in bytes"}{$size}-byte binary file{/msg}
-  </div>
-{/if}
 {/template}
 
 /**