Merge "ChangeScreen2: Display a list of related commits"
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
index 208621a..83215d9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
@@ -139,6 +139,7 @@
   @UiField ListBox revisionList;
   @UiField Labels labels;
   @UiField CommitBox commit;
+  @UiField RelatedChanges related;
   @UiField FileTable files;
   @UiField FlowPanel history;
 
@@ -243,12 +244,17 @@
     handlers.add(GlobalKey.add(this, keysNavigation));
     handlers.add(GlobalKey.add(this, keysAction));
     files.registerKeys();
+    related.registerKeys();
   }
 
   @Override
   public void onShowView() {
     super.onShowView();
 
+    related.setMaxHeight(commit.getElement()
+        .getParentElement()
+        .getOffsetHeight());
+
     String prior = Gerrit.getPriorView();
     if (prior != null && prior.startsWith("/c/")) {
       scrollToPath(prior.substring(3));
@@ -545,6 +551,7 @@
     reload.set(info);
     topic.set(info);
     commit.set(commentLinkProcessor, info, revision);
+    related.set(info, revision);
     quickApprove.set(info, revision);
 
     if (Gerrit.isSignedIn()) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml
index 6a0b717..d50acc9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml
@@ -121,10 +121,13 @@
       color: red;
     }
 
-    .commitColumn {
+    .commitColumn, .related {
       padding: 0;
       vertical-align: top;
     }
+    .commitColumn {
+      width: 600px;
+    }
 
     .labels {
       border-spacing: 0;
@@ -279,6 +282,10 @@
         <td class='{style.commitColumn}'>
           <c:CommitBox ui:field='commit'/>
         </td>
+
+        <td class='{style.related}'>
+          <c:RelatedChanges ui:field='related'/>
+        </td>
       </tr>
     </table>
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java
new file mode 100644
index 0000000..e7b5d47
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// 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.gerrit.client.change;
+
+interface Constants extends com.google.gwt.i18n.client.Constants {
+  String previousChange();
+  String nextChange();
+  String openChange();
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties
new file mode 100644
index 0000000..f5971b6
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties
@@ -0,0 +1,3 @@
+previousChange = Previous related change
+nextChange = Next related change
+openChange = Open related change
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
index 2cf1caf..a637fbe 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
@@ -57,7 +57,7 @@
 import java.util.Comparator;
 
 class FileTable extends FlowPanel {
-  private static final FileTableResources R = GWT
+  static final FileTableResources R = GWT
       .create(FileTableResources.class);
 
   interface FileTableResources extends ClientBundle {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
new file mode 100644
index 0000000..c4c9e09
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
@@ -0,0 +1,338 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// 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.gerrit.client.change;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.GitwebLink;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.ChangeInfo;
+import com.google.gerrit.client.changes.ChangeInfo.CommitInfo;
+import com.google.gerrit.client.ui.NavigationTable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.Scheduler;
+import com.google.gwt.core.client.Scheduler.RepeatingCommand;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.NativeEvent;
+import com.google.gwt.resources.client.CssResource;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Event;
+import com.google.gwt.user.client.EventListener;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.ScrollPanel;
+import com.google.gwt.user.client.ui.UIObject;
+import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
+import com.google.gwtexpui.progress.client.ProgressBar;
+import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
+
+class RelatedChanges extends Composite {
+  interface Binder extends UiBinder<HTMLPanel, RelatedChanges> {}
+  private static Binder uiBinder = GWT.create(Binder.class);
+
+  private static final String OPEN;
+  private static final HyperlinkImpl link = GWT.create(HyperlinkImpl.class);
+
+  static {
+    OPEN = DOM.createUniqueId().replace('-', '_');
+    init(OPEN);
+  }
+
+  private static final native void init(String o) /*-{
+    $wnd[o] = $entry(function(e,i) {
+      return @com.google.gerrit.client.change.RelatedChanges::onOpen(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i);
+    });
+  }-*/;
+
+  private static boolean onOpen(NativeEvent e, int idx) {
+    if (link.handleAsClick(e.<Event> cast())) {
+      MyTable t = getMyTable(e);
+      if (t != null) {
+        t.onOpenRow(idx);
+        e.preventDefault();
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static MyTable getMyTable(NativeEvent event) {
+    com.google.gwt.user.client.Element e = event.getEventTarget().cast();
+    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
+      EventListener l = DOM.getEventListener(e);
+      if (l instanceof MyTable) {
+        return (MyTable) l;
+      }
+    }
+    return null;
+  }
+
+  interface Style extends CssResource {
+    String subject();
+  }
+
+  private String project;
+  private MyTable table;
+  private boolean register;
+
+  @UiField Style style;
+  @UiField Element header;
+  @UiField ScrollPanel scroll;
+  @UiField ProgressBar progress;
+  @UiField Element error;
+
+  RelatedChanges() {
+    initWidget(uiBinder.createAndBindUi(this));
+  }
+
+  void set(ChangeInfo info, final String revision) {
+    if (info.status().isClosed()) {
+      setVisible(false);
+      return;
+    }
+
+    project = info.project();
+
+    ChangeApi.revision(info.legacy_id().get(), revision)
+      .view("related")
+      .get(new AsyncCallback<RelatedInfo>() {
+        @Override
+        public void onSuccess(RelatedInfo result) {
+          render(revision, result.changes());
+        }
+
+        @Override
+        public void onFailure(Throwable err) {
+          progress.setVisible(false);
+          scroll.setVisible(false);
+          UIObject.setVisible(error, true);
+          error.setInnerText(err.getMessage());
+        }
+      });
+  }
+
+  void setMaxHeight(int height) {
+    int h = height - header.getOffsetHeight();
+    scroll.setHeight(h + "px");
+  }
+
+  void registerKeys() {
+    register = true;
+
+    if (table != null) {
+      table.setRegisterKeys(true);
+    }
+  }
+
+  private void render(String revision, JsArray<ChangeAndCommit> graph) {
+    DisplayCommand cmd = new DisplayCommand(revision, graph);
+    if (cmd.execute()) {
+      Scheduler.get().scheduleIncremental(cmd);
+    }
+  }
+
+  private void setTable(MyTable t) {
+    progress.setVisible(false);
+
+    scroll.clear();
+    scroll.add(t);
+    scroll.setVisible(true);
+    table = t;
+
+    if (register) {
+      table.setRegisterKeys(true);
+    }
+  }
+
+  private String url(ChangeAndCommit c) {
+    if (c.has_change_number() && c.has_revision_number()) {
+      PatchSet.Id id = c.patch_set_id();
+      return "#" + PageLinks.toChange2(
+          id.getParentKey(),
+          String.valueOf(id.get()));
+    }
+
+    GitwebLink gw = Gerrit.getGitwebLink();
+    if (gw != null) {
+      return gw.toRevision(project, c.commit().commit());
+    }
+    return null;
+  }
+
+  private class MyTable extends NavigationTable<ChangeAndCommit> {
+    private final JsArray<ChangeAndCommit> list;
+
+    MyTable(JsArray<ChangeAndCommit> list) {
+      this.list = list;
+      table.setWidth("");
+
+      keysNavigation.setName(Gerrit.C.sectionNavigation());
+      keysNavigation.add(new PrevKeyCommand(0, 'K',
+          Resources.C.previousChange()));
+      keysNavigation.add(new NextKeyCommand(0, 'J', Resources.C.nextChange()));
+      keysNavigation.add(new OpenKeyCommand(0, 'O', Resources.C.openChange()));
+    }
+
+    @Override
+    protected Object getRowItemKey(ChangeAndCommit item) {
+      return item.id();
+    }
+
+    @Override
+    protected ChangeAndCommit getRowItem(int row) {
+      if (0 <= row && row <= list.length()) {
+        return list.get(row);
+      }
+      return null;
+    }
+
+    @Override
+    protected void onOpenRow(int row) {
+      if (0 <= row && row <= list.length()) {
+        ChangeAndCommit c = list.get(row);
+        String url = url(c);
+        if (url != null && url.startsWith("#")) {
+          Gerrit.display(url.substring(1));
+        } else if (url != null) {
+          Window.Location.assign(url);
+        }
+      }
+    }
+
+    void selectRow(int select) {
+      movePointerTo(select, true);
+    }
+  }
+
+  private final class DisplayCommand implements RepeatingCommand {
+    private final SafeHtmlBuilder sb = new SafeHtmlBuilder();
+    private final MyTable table;
+    private final String revision;
+    private final JsArray<ChangeAndCommit> list;
+    private boolean attached;
+    private int row;
+    private int select;
+    private double start;
+
+    private DisplayCommand(String revision, JsArray<ChangeAndCommit> list) {
+      this.table = new MyTable(list);
+      this.revision = revision;
+      this.list = list;
+    }
+
+    public boolean execute() {
+      boolean attachedNow = isAttached();
+      if (!attached && attachedNow) {
+        // Remember that we have been attached at least once. If
+        // later we find we aren't attached we should stop running.
+        attached = true;
+      } else if (attached && !attachedNow) {
+        // If the user navigated away, we aren't in the DOM anymore.
+        // Don't continue to render.
+        return false;
+      }
+
+      start = System.currentTimeMillis();
+      while (row < list.length()) {
+        ChangeAndCommit info = list.get(row);
+        if (revision.equals(info.commit().commit())) {
+          select = row;
+        }
+        render(sb, row, info);
+        if ((++row % 10) == 0 && longRunning()) {
+          updateMeter();
+          return true;
+        }
+      }
+      table.resetHtml(sb);
+      setTable(table);
+      table.selectRow(select);
+      return false;
+    }
+
+    private void render(SafeHtmlBuilder sb, int row, ChangeAndCommit info) {
+      sb.openTr();
+      sb.openTd().setStyleName(FileTable.R.css().pointer()).closeTd();
+
+      sb.openTd().addStyleName(style.subject());
+      String url = url(info);
+      if (url != null) {
+        sb.openAnchor().setAttribute("href", url);
+        if (url.startsWith("#")) {
+          sb.setAttribute("onclick", OPEN + "(event," + row + ")");
+        }
+        sb.append(info.commit().subject());
+        sb.closeAnchor();
+      } else {
+        sb.append(info.commit().subject());
+      }
+      sb.closeTd();
+
+      sb.closeTr();
+    }
+
+    private void updateMeter() {
+      progress.setValue((100 * row) / list.length());
+    }
+
+    private boolean longRunning() {
+      return System.currentTimeMillis() - start > 200;
+    }
+  }
+
+  private static class RelatedInfo extends JavaScriptObject {
+    final native JsArray<ChangeAndCommit> changes() /*-{ return this.changes }-*/;
+    protected RelatedInfo() {
+    }
+  }
+
+  private static class ChangeAndCommit extends JavaScriptObject {
+    final native String id() /*-{ return this.change_id }-*/;
+    final native CommitInfo commit() /*-{ return this.commit }-*/;
+
+    final Change.Id legacy_id() {
+      return has_change_number() ? new Change.Id(_change_number()) : null;
+    }
+
+    final PatchSet.Id patch_set_id() {
+      return has_change_number() && has_revision_number()
+          ? new PatchSet.Id(legacy_id(), _revision_number())
+          : null;
+    }
+
+    private final native boolean has_change_number()
+    /*-{ return this.hasOwnProperty('_change_number') }-*/;
+
+    private final native boolean has_revision_number()
+    /*-{ return this.hasOwnProperty('_revision_number') }-*/;
+
+    private final native int _change_number()
+    /*-{ return this._change_number }-*/;
+
+    private final native int _revision_number()
+    /*-{ return this._revision_number }-*/;
+
+    protected ChangeAndCommit() {
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.ui.xml
new file mode 100644
index 0000000..1fbaff8
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.ui.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+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.
+-->
+<ui:UiBinder
+    xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:x='urn:import:com.google.gwtexpui.progress.client'>
+  <ui:style type='com.google.gerrit.client.change.RelatedChanges.Style'>
+    .subject {
+      width: 200px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  </ui:style>
+
+  <g:HTMLPanel>
+    <div ui:field='header'>
+      <div title='Same branch changes connected by Git history'>
+        <ui:attribute name='title'/>
+        <ui:msg>Related Changes</ui:msg>
+      </div>
+    </div>
+    <g:ScrollPanel ui:field='scroll' visible='false'/>
+    <x:ProgressBar ui:field='progress'/>
+    <div ui:field='error' aria-hidden='true' style='display: NONE'/>
+  </g:HTMLPanel>
+</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
index 4b1681e..3f6cfe6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
@@ -21,6 +21,7 @@
 
 public interface Resources extends ClientBundle {
   public static final Resources I = GWT.create(Resources.class);
+  static final Constants C = GWT.create(Constants.class);
 
   @Source("star_open.png") ImageResource star_open();
   @Source("star_filled.png") ImageResource star_filled();
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAncestorAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAncestorAccess.java
index f2b1cb7..ac2c849 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAncestorAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAncestorAccess.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.server;
 
+import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetAncestor;
 import com.google.gerrit.reviewdb.client.RevId;
@@ -31,6 +32,9 @@
   @Query("WHERE key.patchSetId = ? ORDER BY key.position")
   ResultSet<PatchSetAncestor> ancestorsOf(PatchSet.Id id) throws OrmException;
 
+  @Query("WHERE key.patchSetId.changeId = ?")
+  ResultSet<PatchSetAncestor> byChange(Id id) throws OrmException;
+
   @Query("WHERE key.patchSetId = ?")
   ResultSet<PatchSetAncestor> byPatchSet(PatchSet.Id id) throws OrmException;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
new file mode 100644
index 0000000..f0a1738
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
@@ -0,0 +1,292 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// 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.gerrit.server.change;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetAncestor;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeJson.CommitInfo;
+import com.google.gerrit.server.change.ChangeJson.GitPerson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+public class GetRelated implements RestReadView<RevisionResource> {
+  private static final Logger log = LoggerFactory.getLogger(GetRelated.class);
+
+  private final GitRepositoryManager gitMgr;
+  private final Provider<ReviewDb> dbProvider;
+
+  @Inject
+  GetRelated(GitRepositoryManager gitMgr, Provider<ReviewDb> db) {
+    this.gitMgr = gitMgr;
+    this.dbProvider = db;
+  }
+
+  @Override
+  public Object apply(RevisionResource rsrc)
+      throws RepositoryNotFoundException, IOException, OrmException {
+    Repository git = gitMgr.openRepository(rsrc.getChange().getProject());
+    try {
+      Ref ref = git.getRef(rsrc.getChange().getDest().get());
+      RevWalk rw = new RevWalk(git);
+      try {
+        RelatedInfo info = new RelatedInfo();
+        info.changes = walk(rsrc, rw, ref);
+        return info;
+      } finally {
+        rw.release();
+      }
+    } finally {
+      git.close();
+    }
+  }
+
+  private List<ChangeAndCommit> walk(RevisionResource rsrc, RevWalk rw, Ref ref)
+      throws OrmException, IOException {
+    Map<Change.Id, Change> changes = allOpenChanges(rsrc);
+    Map<PatchSet.Id, PatchSet> patchSets = allPatchSets(changes.keySet());
+    List<ChangeAndCommit> graph = children(rsrc, rw, changes, patchSets);
+
+    Map<String, PatchSet> commits = Maps.newHashMap();
+    for (PatchSet p : patchSets.values()) {
+      commits.put(p.getRevision().get(), p);
+    }
+
+    RevCommit rev = rw.parseCommit(ObjectId.fromString(
+        rsrc.getPatchSet().getRevision().get()));
+    rw.sort(RevSort.TOPO);
+    rw.markStart(rev);
+
+    if (ref != null && ref.getObjectId() != null) {
+      try {
+        rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+      } catch (IncorrectObjectTypeException notCommit) {
+        // Ignore and treat as new branch.
+      }
+    }
+
+    for (RevCommit c; (c = rw.next()) != null;) {
+      PatchSet p = commits.get(c.name());
+      Change g = p != null ? changes.get(p.getId().getParentKey()) : null;
+      graph.add(new ChangeAndCommit(g, p, c));
+    }
+    return graph;
+  }
+
+  private Map<Change.Id, Change> allOpenChanges(RevisionResource rsrc)
+      throws OrmException {
+    ReviewDb db = dbProvider.get();
+    return db.changes().toMap(
+        db.changes().byBranchOpenAll(rsrc.getChange().getDest()));
+  }
+
+  private Map<PatchSet.Id, PatchSet> allPatchSets(Collection<Change.Id> ids)
+      throws OrmException {
+    int n = ids.size();
+    ReviewDb db = dbProvider.get();
+    List<ResultSet<PatchSet>> t = Lists.newArrayListWithCapacity(n);
+    for (Change.Id id : ids) {
+      t.add(db.patchSets().byChange(id));
+    }
+
+    Map<PatchSet.Id, PatchSet> r = Maps.newHashMapWithExpectedSize(n * 2);
+    for (ResultSet<PatchSet> rs : t) {
+      for (PatchSet p : rs) {
+        r.put(p.getId(), p);
+      }
+    }
+    return r;
+  }
+
+  private List<ChangeAndCommit> children(RevisionResource rsrc, RevWalk rw,
+      Map<Change.Id, Change> changes, Map<PatchSet.Id, PatchSet> patchSets)
+      throws OrmException, IOException {
+    // children is a map of parent commit name to PatchSet built on it.
+    Multimap<String, PatchSet.Id> children = allChildren(changes.keySet());
+
+    RevFlag seenCommit = rw.newFlag("seenCommit");
+    LinkedList<String> q = Lists.newLinkedList();
+    seedQueue(rsrc, rw, seenCommit, patchSets, q);
+
+    ProjectControl projectCtl = rsrc.getControl().getProjectControl();
+    Set<Change.Id> seenChange = Sets.newHashSet();
+    List<ChangeAndCommit> graph = Lists.newArrayList();
+    while (!q.isEmpty()) {
+      String id = q.remove();
+
+      // For every matching change find the most recent patch set.
+      Map<Change.Id, PatchSet.Id> matches = Maps.newHashMap();
+      for (PatchSet.Id psId : children.get(id)) {
+        PatchSet.Id e = matches.get(psId.getParentKey());
+        if ((e == null || e.get() < psId.get())
+            && isVisible(projectCtl, changes, patchSets, e))  {
+          matches.put(psId.getParentKey(), psId);
+        }
+      }
+
+      for (Map.Entry<Change.Id, PatchSet.Id> e : matches.entrySet()) {
+        Change change = changes.get(e.getKey());
+        PatchSet ps = patchSets.get(e.getValue());
+        if (change == null || ps == null || !seenChange.add(e.getKey())) {
+          continue;
+        }
+
+        RevCommit c = rw.parseCommit(ObjectId.fromString(
+            ps.getRevision().get()));
+        if (!c.has(seenCommit)) {
+          c.add(seenCommit);
+          q.addFirst(ps.getRevision().get());
+          graph.add(new ChangeAndCommit(change, ps, c));
+        }
+      }
+    }
+    Collections.reverse(graph);
+    return graph;
+  }
+
+  private boolean isVisible(ProjectControl projectCtl,
+      Map<Change.Id, Change> changes,
+      Map<PatchSet.Id, PatchSet> patchSets,
+      PatchSet.Id psId) throws OrmException {
+    Change c = changes.get(psId.getParentKey());
+    PatchSet ps = patchSets.get(psId);
+    if (c != null && ps != null) {
+      ChangeControl ctl = projectCtl.controlFor(c);
+      return ctl.isVisible(dbProvider.get())
+          && ctl.isPatchVisible(ps, dbProvider.get());
+    }
+    return false;
+  }
+
+  private void seedQueue(RevisionResource rsrc, RevWalk rw,
+      RevFlag seenCommit, Map<PatchSet.Id, PatchSet> patchSets,
+      LinkedList<String> q) throws IOException {
+    RevCommit tip = rw.parseCommit(ObjectId.fromString(
+        rsrc.getPatchSet().getRevision().get()));
+    tip.add(seenCommit);
+    q.add(tip.name());
+
+    Change.Id cId = rsrc.getChange().getId();
+    for (PatchSet p : patchSets.values()) {
+      if (cId.equals(p.getId().getParentKey())) {
+        try {
+          RevCommit c = rw.parseCommit(ObjectId.fromString(
+              rsrc.getPatchSet().getRevision().get()));
+          if (!c.has(seenCommit)) {
+            c.add(seenCommit);
+            q.add(c.name());
+          }
+        } catch (IOException e) {
+          log.warn(String.format(
+              "Cannot read patch set %d of %d",
+              p.getPatchSetId(), cId.get()), e);
+        }
+      }
+    }
+  }
+
+  private Multimap<String, PatchSet.Id> allChildren(Collection<Change.Id> ids)
+      throws OrmException {
+    ReviewDb db = dbProvider.get();
+    List<ResultSet<PatchSetAncestor>> t =
+        Lists.newArrayListWithCapacity(ids.size());
+    for (Change.Id id : ids) {
+      t.add(db.patchSetAncestors().byChange(id));
+    }
+
+    Multimap<String, PatchSet.Id> r = ArrayListMultimap.create();
+    for (ResultSet<PatchSetAncestor> rs : t) {
+      for (PatchSetAncestor a : rs) {
+        r.put(a.getAncestorRevision().get(), a.getPatchSet());
+      }
+    }
+    return r;
+  }
+
+  private static GitPerson toGitPerson(PersonIdent id) {
+    GitPerson p = new GitPerson();
+    p.name = id.getName();
+    p.email = id.getEmailAddress();
+    p.date = new Timestamp(id.getWhen().getTime());
+    p.tz = id.getTimeZoneOffset();
+    return p;
+  }
+
+  static class RelatedInfo {
+    List<ChangeAndCommit> changes;
+  }
+
+  static class ChangeAndCommit {
+    String changeId;
+    CommitInfo commit;
+    Integer _changeNumber;
+    Integer _revisionNumber;
+
+    ChangeAndCommit(@Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+      if (change != null) {
+        changeId = change.getKey().get();
+        _changeNumber = change.getChangeId();
+        _revisionNumber = ps != null ? ps.getPatchSetId() : null;
+      }
+
+      commit = new CommitInfo();
+      commit.commit = c.name();
+      commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
+      for (int i = 0; i < c.getParentCount(); i++) {
+        CommitInfo p = new CommitInfo();
+        p.commit = c.getParent(i).name();
+        commit.parents.add(p);
+      }
+      commit.author = toGitPerson(c.getAuthorIdent());
+      commit.subject = c.getShortMessage();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index a9e3604..814adde 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -64,6 +64,7 @@
     post(REVISION_KIND, "cherrypick").to(CherryPick.class);
     get(REVISION_KIND, "commit").to(GetCommit.class);
     get(REVISION_KIND, "mergeable").to(Mergeable.class);
+    get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
     post(REVISION_KIND, "submit").to(Submit.class);