Merge "Nearly convert <gr-rest-api-interface> element to a simple class"
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
index 183f6d0..c8c8a76 100644
--- a/java/com/google/gerrit/entities/CommentContext.java
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -20,10 +20,16 @@
 /** An entity class representing all context lines of a comment. */
 @AutoValue
 public abstract class CommentContext {
+  private static final CommentContext EMPTY = new AutoValue_CommentContext(ImmutableMap.of());
+
   public static CommentContext create(ImmutableMap<Integer, String> lines) {
     return new AutoValue_CommentContext(lines);
   }
 
   /** Map of {line number, line text} of the context lines of a comment */
   public abstract ImmutableMap<Integer, String> lines();
+
+  public static CommentContext empty() {
+    return EMPTY;
+  }
 }
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
index 68a80c3..0422cb9 100644
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -84,6 +84,7 @@
         for (Comment comment : commentsByCommitId.get(commitId)) {
           Optional<Range> range = getStartAndEndLines(comment);
           if (!range.isPresent()) {
+            result.put(comment, CommentContext.empty());
             continue;
           }
           // TODO(ghareeb): We can further group the comments by file paths to avoid opening
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index cb4b534..dd39262 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -161,6 +161,7 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
 import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
@@ -243,6 +244,7 @@
     install(ServiceUserClassifierImpl.module());
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
+    install(DiffOperationsImpl.module());
     install(SectionSortCache.module());
     install(SubmitStrategy.module());
     install(TagCache.module());
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
new file mode 100644
index 0000000..de4a10e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2020 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.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** A utility class for computing the base commit / parent for a specific patchset commit. */
+class BaseCommitUtil {
+  private final AutoMerger autoMerger;
+  private final ThreeWayMergeStrategy mergeStrategy;
+
+  /** If true, auto-merge results are stored in the repository. */
+  private final boolean saveAutomerge;
+
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
+    this.autoMerger = am;
+    this.saveAutomerge = AutoMerger.cacheAutomerge(cfg);
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.repoManager = repoManager;
+  }
+
+  RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = newInserter(repo);
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      return getParentCommit(repo, ins, rw, parentNum, newCommit);
+    }
+  }
+
+  /**
+   * Returns the number of parent commits of the commit represented by the commitId parameter.
+   *
+   * @param project a specific git repository.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return an integer representing the number of parents of the designated commit.
+   */
+  int getNumParents(Project.NameKey project, ObjectId commitId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      RevCommit current = rw.parseCommit(commitId);
+      return current.getParentCount();
+    }
+  }
+
+  /**
+   * Returns the parent commit Object of the commit represented by the commitId parameter.
+   *
+   * @param repo a git repository.
+   * @param ins a git object inserter in the database.
+   * @param rw a {@link RevWalk} object of the repository.
+   * @param parentNum used to identify the parent number for merge commits. If parentNum is null and
+   *     {@code commitId} has two parents, the auto-merge commit will be returned. If {@code
+   *     commitId} has a single parent, it will be returned.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Returns the parent commit of the commit represented by the commitId parameter. Note
+   *     that auto-merge is not supported for commits having more than two parents.
+   */
+  RevObject getParentCommit(
+      Repository repo,
+      ObjectInserter ins,
+      RevWalk rw,
+      @Nullable Integer parentNum,
+      ObjectId commitId)
+      throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    switch (current.getParentCount()) {
+      case 0:
+        return rw.parseAny(emptyTree(ins));
+      case 1:
+        return current.getParent(0);
+      default:
+        if (parentNum != null) {
+          RevCommit r = current.getParent(parentNum - 1);
+          rw.parseBody(r);
+          return r;
+        }
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (current.getParentCount() == 2) {
+          return autoMerger.merge(repo, rw, ins, current, mergeStrategy);
+        }
+        return null;
+    }
+  }
+
+  private ObjectInserter newInserter(Repository repo) {
+    return saveAutomerge ? repo.newObjectInserter() : new InMemoryInserter(repo);
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ins.flush();
+    return id;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
new file mode 100644
index 0000000..a6c7c81
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -0,0 +1,72 @@
+//  Copyright (C) 2020 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.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * An interface for all file diff related operations. Clients should use this interface to request:
+ *
+ * <ul>
+ *   <li>The list of modified files between two commits.
+ *   <li>The list of modified files between a commit and its parent or the auto-merge.
+ *   <li>The detailed file diff for a single file path (TODO:ghareeb).
+ *   <li>The Intra-line diffs for a single file path (TODO:ghareeb).
+ * </ul>
+ */
+public interface DiffOperations {
+
+  /**
+   * Returns the list of added, deleted or modified files between a commit against its base. The
+   * {@link Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} (for merge commits) are also returned.
+   *
+   * <p>If parentNum is set, it is used as the old commit in the diff. Otherwise, if the {@code
+   * newCommit} has only one parent, it is used as base. If {@code newCommit} has two parents, the
+   * auto-merge commit is computed and used as base. The auto-merge for more than two parents is not
+   * supported.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if auto-merge is requested for a commit having more than two
+   *     parents, if the {@code newCommit} could not be parsed for extracting the base commit, or if
+   *     an internal error occurred in Git while evaluating the diff.
+   */
+  Map<String, FileDiffOutput> getModifiedFilesAgainstParentOrAutoMerge(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the list of added, deleted or modified files between two commits (patchsets). The
+   * commit message and merge list (for merge commits) are also returned.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  Map<String, FileDiffOutput> getModifiedFilesBetweenPatchsets(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
new file mode 100644
index 0000000..1a8d85b
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -0,0 +1,199 @@
+//  Copyright (C) 2020 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.patch;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.filediff.FileDiffCache;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheImpl;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevObject;
+
+/**
+ * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
+ * diff computation.
+ */
+public class DiffOperationsImpl implements DiffOperations {
+  private static final int RENAME_SCORE = 60;
+  private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = DiffAlgorithm.HISTOGRAM;
+
+  private final ModifiedFilesCache modifiedFilesCache;
+  private final FileDiffCache fileDiffCache;
+  private final BaseCommitUtil baseCommitUtil;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(DiffOperations.class).to(DiffOperationsImpl.class);
+        install(GitModifiedFilesCacheImpl.module());
+        install(ModifiedFilesCacheImpl.module());
+        install(GitFileDiffCacheImpl.module());
+        install(FileDiffCacheImpl.module());
+      }
+    };
+  }
+
+  @Inject
+  public DiffOperationsImpl(
+      ModifiedFilesCache modifiedFilesCache,
+      FileDiffCache fileDiffCache,
+      BaseCommitUtil baseCommit) {
+    this.modifiedFilesCache = modifiedFilesCache;
+    this.fileDiffCache = fileDiffCache;
+    this.baseCommitUtil = baseCommit;
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> getModifiedFilesAgainstParentOrAutoMerge(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
+      throws DiffNotAvailableException {
+    try {
+      if (parent != null) {
+        RevObject base = baseCommitUtil.getBaseCommit(project, newCommit, parent);
+        return getModifiedFiles(project, base, newCommit, ComparisonType.againstParent(parent));
+      }
+      int numParents = baseCommitUtil.getNumParents(project, newCommit);
+      if (numParents > 2) {
+        throw new DiffNotAvailableException(
+            "Diff against auto-merge for merge commits "
+                + "with more than two parents is not supported. Commit "
+                + newCommit
+                + " has "
+                + numParents
+                + " parents");
+      }
+      if (numParents == 1) {
+        RevObject base = baseCommitUtil.getBaseCommit(project, newCommit, parent);
+        ComparisonType cmp = ComparisonType.againstParent(1);
+        return getModifiedFiles(project, base, newCommit, cmp);
+      }
+      RevObject autoMerge = baseCommitUtil.getBaseCommit(project, newCommit, null);
+      return getModifiedFiles(project, autoMerge, newCommit, ComparisonType.againstAutoMerge());
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> getModifiedFilesBetweenPatchsets(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException {
+    ComparisonType cmp = ComparisonType.againstOtherPatchSet();
+    return getModifiedFiles(project, oldCommit, newCommit, cmp);
+  }
+
+  private Map<String, FileDiffOutput> getModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, ComparisonType cmp)
+      throws DiffNotAvailableException {
+    ImmutableMap.Builder<String, FileDiffOutput> files = ImmutableMap.builder();
+    try {
+      ImmutableList<ModifiedFile> modifiedFiles =
+          modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
+
+      List<FileDiffCacheKey> fileCacheKeys =
+          modifiedFiles.stream()
+              .map(
+                  entity ->
+                      createFileDiffCacheKey(
+                          project,
+                          oldCommit,
+                          newCommit,
+                          entity.newPath().isPresent()
+                              ? entity.newPath().get()
+                              : entity.oldPath().get()))
+              .collect(Collectors.toList());
+
+      fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, Patch.COMMIT_MSG));
+
+      if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
+        fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, Patch.MERGE_LIST));
+      }
+
+      ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs =
+          fileDiffCache.getAll(fileCacheKeys);
+
+      for (Map.Entry<FileDiffCacheKey, FileDiffOutput> entry : fileDiffs.entrySet()) {
+        FileDiffOutput fileDiffOutput = entry.getValue();
+        if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+          continue;
+        }
+        if (fileDiffOutput.changeType().get() == Patch.ChangeType.DELETED) {
+          files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
+        } else {
+          files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
+        }
+      }
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(e);
+    }
+    return files.build();
+  }
+
+  private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
+    return fileDiffOutput.allEditsDueToRebase()
+        && (!(fileDiffOutput.changeType().get() == ChangeType.RENAMED
+            || fileDiffOutput.changeType().get() == ChangeType.COPIED));
+  }
+
+  private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
+      throws IOException {
+    return (cmp.isAgainstParent() && baseCommitUtil.getNumParents(project, commit) > 1);
+  }
+
+  private static ModifiedFilesCacheKey createModifiedFilesKey(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit) {
+    return ModifiedFilesCacheKey.builder()
+        .project(project)
+        .aCommit(aCommit)
+        .bCommit(bCommit)
+        .renameScore(RENAME_SCORE)
+        .build();
+  }
+
+  private static FileDiffCacheKey createFileDiffCacheKey(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit, String newPath) {
+    return FileDiffCacheKey.builder()
+        .project(project)
+        .oldCommit(aCommit)
+        .newCommit(bCommit)
+        .newFilePath(newPath)
+        .renameScore(RENAME_SCORE)
+        .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
+        .whitespace(Whitespace.IGNORE_NONE)
+        .build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 2c42d0a..1e4acef 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1091,6 +1091,22 @@
   }
 
   @Test
+  public void commentContextIsEmptyForPatchsetLevelComments() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addComments(changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
   public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/tests.bzl b/javatests/com/google/gerrit/acceptance/tests.bzl
index 08556a0..70c6e94 100644
--- a/javatests/com/google/gerrit/acceptance/tests.bzl
+++ b/javatests/com/google/gerrit/acceptance/tests.bzl
@@ -4,7 +4,7 @@
         group,
         deps = [],
         labels = [],
-        vm_args = ["-Xmx256m"],
+        vm_args = ["-Xmx512m"],
         **kwargs):
     junit_tests(
         name = group,
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index c29663c..2f83182 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -6,6 +6,7 @@
 # This list must be in sync with the "include" list in the follwoing files:
 # tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
 src_dirs = [
+    "api",
     "constants",
     "elements",
     "embed",
diff --git a/polygerrit-ui/app/api/README.md b/polygerrit-ui/app/api/README.md
new file mode 100644
index 0000000..550063f
--- /dev/null
+++ b/polygerrit-ui/app/api/README.md
@@ -0,0 +1,23 @@
+# API
+
+In this folder, we declare the API of various parts of the Gerrit webclient.
+There are two primary use cases for this:
+
+* apps that embed our diff viewer, gr-diff
+* Gerrit plugins that need to access some part of Gerrit to extend it
+
+Both may be built as a separate bundle, but would like to type check against
+the same types the Gerrit/gr-diff bundle uses. For this reason, this folder
+should contain only types, with the exception of enums, where having the
+value side is deemed an acceptable duplication.
+
+All types in here should use the `declare` keyword to prevent bundlers from
+renaming fields, which would break communication across separately built
+bundles. Again enums are the exception, because their keys are not referenced
+across bundles, and values will not be renamed by bundlers as they are strings.
+
+This API is used by other apps embedding gr-diff and any breaking changes
+should be discussed with the Gerrit core team and properly versioned.
+
+Gerrit types should either directly use or extend these types, so that
+breaking changes to the implementation require changes to these files.
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
new file mode 100644
index 0000000..f582915
--- /dev/null
+++ b/polygerrit-ui/app/api/core.ts
@@ -0,0 +1,22 @@
+/**
+ * @fileoverview Core API types for Gerrit.
+ *
+ * Core types are types used in many places in Gerrit, such as the Side enum.
+ *
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// TODO(oler): Copy/move types here.
diff --git a/polygerrit-ui/app/types/diff.d.ts b/polygerrit-ui/app/api/diff.ts
similarity index 70%
rename from polygerrit-ui/app/types/diff.d.ts
rename to polygerrit-ui/app/api/diff.ts
index ed03dcf..59e4d42 100644
--- a/polygerrit-ui/app/types/diff.d.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -1,16 +1,8 @@
 /**
- * @fileoverview The Gerrit diff API.
+ * @fileoverview The API of Gerrit's diff viewer, gr-diff.
  *
- * This API is used by other apps embedding gr-diff and any breaking changes
- * should be discussed with the Gerrit core team and properly versioned.
- *
- * Should only contain types, no values, so that other apps using gr-diff can
- * use this solely to type check and generate externs for their separate ts
- * bundles.
- *
- * Should declare all types, to avoid renaming breaking multi-bundle setups.
- *
- * Enums should be converted to union types to avoid values in this file.
+ * This includes some types which are also defined as part of Gerrit's JSON API
+ * which are used as inputs to gr-diff.
  *
  * @license
  * Copyright (C) 2020 The Android Open Source Project
@@ -29,6 +21,15 @@
  */
 
 /**
+ * Diff type in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+/**
  * The DiffInfo entity contains information about the diff of a file in a
  * revision.
  *
@@ -43,15 +44,8 @@
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
   intraline_status: 'OK' | 'Error' | 'Timeout';
-  /** A list of strings representing the patch set diff header. */
-  diff_header: string[];
   /** The content differences in the file as a list of DiffContent entities. */
   content: DiffContent[];
-  /**
-   * Links to the file diff in external sites as a list of DiffWebLinkInfo
-   * entries.
-   */
-  web_links?: DiffWebLinkInfo[];
   /** Whether the file is binary. */
   binary?: boolean;
 }
@@ -67,8 +61,6 @@
   content_type: string;
   /** The total number of lines in the file. */
   lines: number;
-  /** Links to the file in external sites as a list of WebLinkInfo entries. */
-  web_links: WebLinkInfo[];
   // TODO: Not documented.
   language?: string;
 }
@@ -124,8 +116,6 @@
    * whitespace. When present and true a and b are used instead of ab.
    */
   common?: boolean;
-  // TODO: Undocumented, but used in code.
-  keyLocation?: boolean;
 }
 
 /**
@@ -145,23 +135,6 @@
 }
 
 /**
- * The DiffWebLinkInfo entity describes a link on a diff screen to an external
- * site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
- */
-export declare interface DiffWebLinkInfo {
-  /** The link name. */
-  name: string;
-  /** The link URL. */
-  url: string;
-  /** URL to the icon of the link. */
-  image_url: string;
-  // TODO: Are these really of type string? Not able to trigger them, but the
-  // docs sound more like boolean.
-  show_on_side_by_side_diff_view: string;
-  show_on_unified_diff_view: string;
-}
-/**
  * The DiffIntralineInfo entity contains information about intraline edits in a
  * file.
  *
@@ -180,56 +153,29 @@
 export declare type DiffIntralineInfo = [SkipLength, MarkLength];
 
 /**
- * The WebLinkInfo entity describes a link to an external site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
- */
-export declare interface WebLinkInfo {
-  /** The link name. */
-  name: string;
-  /** The link URL. */
-  url: string;
-  /** URL to the icon of the link. */
-  image_url: string;
-}
-
-/**
  * The DiffPreferencesInfo entity contains information about the diff
  * preferences of a user.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
  */
 export declare interface DiffPreferencesInfo {
   context: number;
-  expand_all_comments?: boolean;
   ignore_whitespace: IgnoreWhitespaceType;
   intraline_difference?: boolean;
   line_length: number;
-  cursor_blink_rate: number;
-  manual_review?: boolean;
-  retain_header?: boolean;
   show_line_endings?: boolean;
   show_tabs?: boolean;
   show_whitespace_errors?: boolean;
-  skip_deleted?: boolean;
   skip_uncommented?: boolean;
   syntax_highlighting?: boolean;
-  hide_top_menu?: boolean;
   auto_hide_diff_table_header?: boolean;
-  hide_line_numbers?: boolean;
   tab_size: number;
   font_size: number;
-  hide_empty_pane?: boolean;
-  match_brackets?: boolean;
-  line_wrapping?: boolean;
-  // TODO(TS): show_file_comment_button exists in JS code, but doesn't exist in
-  // the doc. Either remove or update doc
+  // TODO: Missing documentation
   show_file_comment_button?: boolean;
-  // TODO(TS): theme exists in JS code, but doesn't exist in the doc.
-  // Either remove or update doc
+  // TODO: Missing documentation
   theme?: string;
 }
 
-export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
-
 /**
  * Whether whitespace changes should be ignored and if yes, which whitespace
  * changes should be ignored
@@ -240,3 +186,39 @@
   | 'IGNORE_TRAILING'
   | 'IGNORE_LEADING_AND_TRAILING'
   | 'IGNORE_ALL';
+
+export enum Side {
+  LEFT = 'left',
+  RIGHT = 'right',
+}
+
+export enum CoverageType {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED = 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED = 'NOT_COVERED',
+  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
+
+export declare interface LineRange {
+  start_line: number;
+  end_line: number;
+}
+
+export interface CoverageRange {
+  type: CoverageType;
+  side: Side;
+  code_range: LineRange;
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 50be7ee..b0f87d7 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -18,6 +18,7 @@
 /**
  * @desc Tab names for primary tabs on change view page.
  */
+import {DiffViewMode} from '../api/diff';
 import {DiffPreferencesInfo} from '../types/diff';
 import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
 
@@ -160,10 +161,7 @@
   HIDDEN = 'HIDDEN',
 }
 
-export enum Side {
-  LEFT = 'left',
-  RIGHT = 'right',
-}
+export {Side} from '../api/diff';
 
 /**
  * The type in ConfigParameterInfo entity.
@@ -291,14 +289,7 @@
   HHMM_24 = 'HHMM_24',
 }
 
-/**
- * Diff type in preferences
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
- */
-export enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
+export {DiffViewMode};
 
 /**
  * The type of email strategy to use.
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index c45e801..9ad0b05f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -395,6 +395,7 @@
 
     e.preventDefault();
     this.$.cursor.next();
+    this.selectedIndex = this.$.cursor.index;
   }
 
   _prevChange(e: CustomKeyboardEvent) {
@@ -404,6 +405,7 @@
 
     e.preventDefault();
     this.$.cursor.previous();
+    this.selectedIndex = this.$.cursor.index;
   }
 
   _openChange(e: CustomKeyboardEvent) {
@@ -518,6 +520,8 @@
     afterNextRender(this, () => {
       this.$.cursor.stops = this._getListItems();
       this.$.cursor.moveToStart();
+      if (this.selectedIndex)
+        this.$.cursor.setCursorAtIndex(this.selectedIndex);
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index 990525c..75ed783 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -159,7 +159,6 @@
   </table>
   <gr-cursor-manager
     id="cursor"
-    index="{{selectedIndex}}"
     scroll-mode="keep-visible"
     focus-on-move=""
   ></gr-cursor-manager>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index e40f6f4..7008b48 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -117,6 +117,9 @@
   @property({type: Boolean})
   _showNewUserHelp = false;
 
+  @property({type: Number})
+  _selectedChangeIndex?: number;
+
   private reporting = appContext.reportingService;
 
   private restApiService = appContext.restApiService;
@@ -204,12 +207,21 @@
     return params.view === GerritView.DASHBOARD;
   }
 
+  @observe('_selectedChangeIndex')
+  _selectedChangeIndexChanged(selectedChangeIndex: number) {
+    if (!this.params || !this._isViewActive(this.params)) return;
+    if (!this.viewState) throw new Error('view state undefined');
+    if (!this.params.user) throw new Error('user for dashboard is undefined');
+    this.viewState[this.params.user] = selectedChangeIndex;
+  }
+
   @observe('params.*')
   _paramsChanged(
     paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
   ) {
     const params = paramsChangeRecord.base;
-
+    if (params && this._isViewActive(params) && params.user && this.viewState)
+      this._selectedChangeIndex = this.viewState[params.user] || 0;
     return this._reload(params);
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index c23a1cc..b28dfef 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -84,7 +84,7 @@
       show-reviewed-state=""
       account="[[account]]"
       preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
+      selected-index="{{_selectedChangeIndex}}"
       sections="[[_results]]"
       on-toggle-star="_handleToggleStar"
       on-toggle-reviewed="_handleToggleReviewed"
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 2fd2232..47f885b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -402,5 +402,26 @@
     await paramsChangedPromise;
     assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
   });
+
+  test('selectedChangeIndex is derived from the params', () => {
+    stubRestApi('getDashboard').returns(Promise.resolve({
+      title: 'title',
+      sections: [],
+    }));
+    element.viewState = {
+      101001: 23,
+    };
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+      user: '101001',
+    };
+    flush();
+    sinon.stub(element.reporting, 'dashboardDisplayed');
+    paramsChangedPromise.then(() => {
+      assert.equal(element._selectedChangeIndex, 23);
+    });
+  });
 });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index daa7274..bd2f105 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -1605,12 +1605,12 @@
 
   // TODO(rmistry): Redo this after
   // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
-    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-    if (!labels) {
+  _setReviewOnRevert(newChangeId: NumericChangeId) {
+    const review = this.$.jsAPI.getReviewPostRevert(this.change);
+    if (!review) {
       return Promise.resolve(undefined);
     }
-    return this.restApiService.saveChangeReview(newChangeId, CURRENT, {labels});
+    return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
   }
 
   _handleResponse(action: UIActionInfo, response?: Response) {
@@ -1622,7 +1622,7 @@
         case ChangeActions.REVERT: {
           const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
           this._waitForChangeReachable(revertChangeInfo._number)
-            .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+            .then(() => this._setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
               GerritNav.navigateToChange(revertChangeInfo);
             });
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index 0641182..b58519b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -464,16 +464,16 @@
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
-    test('_setLabelValuesOnRevert', () => {
-      const labels = {'Foo': 1, 'Bar-Baz': -2};
+    test('_setReviewOnRevert', () => {
+      const review = {labels: {'Foo': 1, 'Bar-Baz': -2}};
       const changeId = 1234;
-      sinon.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
+      sinon.stub(element.$.jsAPI, 'getReviewPostRevert').returns(review);
       const saveStub = stubRestApi('saveChangeReview')
           .returns(Promise.resolve());
-      return element._setLabelValuesOnRevert(changeId).then(() => {
+      return element._setReviewOnRevert(changeId).then(() => {
         assert.isTrue(saveStub.calledOnce);
         assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+        assert.deepEqual(saveStub.lastCall.args[2], review);
       });
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 8657e0c..1e3dfff 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -60,7 +60,9 @@
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {DiffViewMode} from '../../../api/diff';
 import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
 import {appContext} from '../../../services/app-context';
 import {ChangeStatus} from '../../../constants/constants';
@@ -192,11 +194,6 @@
   NEW_MESSAGE: 'There are new messages on this change',
 };
 
-enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
-
 const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
 const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
 const SEND_REPLY_TIMING_LABEL = 'SendReply';
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index c2381bc..f27c2ee 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {css, customElement, property} from 'lit-element';
+import {css, customElement, property, PropertyValues} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {
   Category,
@@ -47,12 +47,23 @@
   @property()
   result?: RunResult;
 
+  @property()
+  isExpanded = false;
+
+  @property({type: Boolean, reflect: true})
+  isExpandable = false;
+
   static get styles() {
     return [
       sharedStyles,
       css`
         :host {
-          display: table-row;
+          display: contents;
+        }
+        :host([isexpandable]) {
+          cursor: pointer;
+        }
+        tr {
           border-top: 1px solid var(--border-color);
         }
         iron-icon.launch {
@@ -123,39 +134,71 @@
     ];
   }
 
+  update(changedProperties: PropertyValues) {
+    if (changedProperties.has('result')) {
+      this.isExpandable = !!this.result?.message;
+    }
+    super.update(changedProperties);
+  }
+
   render() {
     if (!this.result) return '';
     return html`
-      <td class="iconCol">
-        <div>${this.renderIcon()}</div>
-      </td>
-      <td class="nameCol">
-        <div><span>${this.result.checkName}</span></div>
-      </td>
-      <td class="summaryCol">
-        <div class="summary-cell">
-          ${(this.result.links ?? []).map(this.renderLink)}
-          <!-- The &nbsp; is for being able to shrink a tiny amount without
-               the text itself getting shrunk with an ellipsis. -->
-          <div class="summary">${this.result.summary}&nbsp;</div>
-          <div class="message">${this.result.message}</div>
-          <div class="tags">
-            ${(this.result.tags ?? []).map(t => this.renderTag(t))}
+      <tr class="container" @click="${this.toggleExpanded}">
+        <td class="iconCol">
+          <div>${this.renderIcon()}</div>
+        </td>
+        <td class="nameCol">
+          <div><span>${this.result.checkName}</span></div>
+        </td>
+        <td class="summaryCol">
+          <div class="summary-cell">
+            ${(this.result.links ?? []).map(this.renderLink)}
+            <!-- The &nbsp; is for being able to shrink a tiny amount without
+                 the text itself getting shrunk with an ellipsis. -->
+            <div class="summary">${this.result.summary}&nbsp;</div>
+            <div class="message">
+              ${this.isExpanded ? '' : this.result.message}
+            </div>
+            <div class="tags">
+              ${(this.result.tags ?? []).map(t => this.renderTag(t))}
+            </div>
+            ${this.renderLabel()}
           </div>
-          ${this.renderLabel()}
-        </div>
-      </td>
-      <td class="expanderCol">
-        <div>
-          <iron-icon
-            aria-label="expand result row"
-            icon="gr-icons:expand-more"
-          ></iron-icon>
-        </div>
-      </td>
+          <gr-result-expanded
+            .result="${this.result}"
+            ?hidden="${!this.isExpanded}"
+          ></gr-result-expanded>
+        </td>
+        <td class="expanderCol">
+          <div
+            class="show-hide"
+            role="switch"
+            tabindex="0"
+            ?hidden="${!this.isExpandable}"
+            ?aria-checked="${this.isExpanded}"
+            aria-label="${this.isExpanded
+              ? 'Collapse result row'
+              : 'Expand result row'}"
+            @click="${this.toggleExpanded}"
+            @keydown="${this.toggleExpanded}"
+          >
+            <iron-icon
+              icon="${this.isExpanded
+                ? 'gr-icons:expand-less'
+                : 'gr-icons:expand-more'}"
+            ></iron-icon>
+          </div>
+        </td>
+      </tr>
     `;
   }
 
+  private toggleExpanded() {
+    if (!this.isExpandable) return;
+    this.isExpanded = !this.isExpanded;
+  }
+
   renderLink(link: Link) {
     return html`
       <a href="${link.url}" target="_blank">
@@ -184,6 +227,32 @@
   }
 }
 
+@customElement('gr-result-expanded')
+class GrResultExpanded extends GrLitElement {
+  @property()
+  result?: RunResult;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .message {
+          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.result) return '';
+    return html`
+      <div class="message">
+        ${this.result.message}
+      </div>
+    `;
+  }
+}
+
 @customElement('gr-checks-results')
 export class GrChecksResults extends GrLitElement {
   @property()
@@ -315,6 +384,7 @@
 declare global {
   interface HTMLElementTagNameMap {
     'gr-result-row': GrResultRow;
+    'gr-result-expanded': GrResultExpanded;
     'gr-checks-results': GrChecksResults;
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 2482497..c91c82e 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -156,6 +156,9 @@
   @property({type: String})
   _registerURL = '';
 
+  @property({type: String})
+  _feedbackURL = '';
+
   @property({type: Boolean})
   mobileSearchHidden = false;
 
@@ -307,6 +310,7 @@
         if (!config) {
           throw new Error('getConfig returned undefined');
         }
+        this._retreiveFeedbackURL(config);
         this._retrieveRegisterURL(config);
         return getDocsBaseUrl(config, this.restApiService);
       })
@@ -327,6 +331,12 @@
     });
   }
 
+  _retreiveFeedbackURL(config: ServerInfo) {
+    if (config.gerrit?.report_bug_url) {
+      this._feedbackURL = config.gerrit.report_bug_url;
+    }
+  }
+
   _retrieveRegisterURL(config: ServerInfo) {
     if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
       this._registerURL = config.auth.register_url ?? '';
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
index 0cdd0f2..28bb090 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -206,6 +206,17 @@
         class="hideOnMobile"
         name="header-browse-source"
       ></gr-endpoint-decorator>
+      <template is="dom-if" if="[[_feedbackURL]]">
+        <a class="feedbackButton"
+          href$="[[_feedbackURL]]"
+          title="File a bug"
+          aria-label="File a bug"
+          role="button"
+        >
+          <iron-icon icon="gr-icons:bug"></iron-icon>
+        </a>
+      </template>
+      </div>
       <div class="accountContainer" id="accountContainer">
         <iron-icon
           id="mobileSearch"
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index b13e2e6..7430cc0 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -21,6 +21,7 @@
 import {GrMainHeader} from './gr-main-header';
 import {
   createAccountDetailWithId,
+  createGerritInfo,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {NavLink} from '../../../utils/admin-nav-util';
@@ -475,6 +476,25 @@
     );
   });
 
+  test('shows feedback icon when URL provided', async () => {
+    assert.isEmpty(element._feedbackURL);
+    assert.isNull(query(element, '.feedbackButton'));
+
+    const url = 'report_bug_url';
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: {
+        ...createGerritInfo(),
+        report_bug_url: url,
+      },
+    };
+    element._retreiveFeedbackURL(config);
+    await flush();
+
+    assert.equal(element._feedbackURL, url);
+    assert.ok(query(element, '.feedbackButton'));
+  });
+
   test('register URL', () => {
     assert.isTrue(isHidden(query(element, '.registerDiv')));
     const config: ServerInfo = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index 6e613d3..3298745 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -43,6 +43,7 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {DiffViewMode} from '../../../api/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
@@ -50,11 +51,6 @@
 import {getLineNumber} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
 // https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 532922c..4dbac66 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -28,6 +28,7 @@
 import {GrDiffBuilder} from './gr-diff-builder.js';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {DiffViewMode} from '../../../api/diff.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
@@ -46,11 +47,6 @@
     </gr-diff-builder>
 `);
 
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
 suite('gr-diff-builder tests', () => {
   let prefs;
   let element;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 52ac7cc..74c339a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -29,6 +29,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-cursor_html';
+import {DiffViewMode} from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
@@ -38,11 +39,6 @@
 import {GrDiff} from '../gr-diff/gr-diff';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
 const LEFT_SIDE_CLASS = 'target-side-left';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 58c40e5..7610e5e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -472,13 +472,13 @@
         this.projectName,
         this.commitRange.baseCommit,
         this.path,
-        {weblinks: diff && diff.meta_a && diff.meta_a.web_links}
+        {weblinks: diff?.meta_a?.web_links}
       ),
       meta_b: GerritNav.getFileWebLinks(
         this.projectName,
         this.commitRange.commit,
         this.path,
-        {weblinks: diff && diff.meta_b && diff.meta_b.web_links}
+        {weblinks: diff?.meta_b?.web_links}
       ),
     };
   }
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index f6477a9..15e8923 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -277,9 +277,7 @@
         offset: 0,
         selectedChangeIndex: 0,
       },
-      dashboardView: {
-        selectedChangeIndex: 0,
-      },
+      dashboardView: {},
     };
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 5658f13..1d54896 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -20,9 +20,11 @@
 import {getPluginLoader} from './gr-plugin-loader';
 
 import {customElement} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
   LabelNameToValuesMap,
+  ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
@@ -307,16 +309,21 @@
     return links;
   }
 
-  getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
-    let labels: LabelNameToValuesMap = {};
+  getReviewPostRevert(change?: ChangeInfo): ReviewInput {
+    let review: ReviewInput = {};
     for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
       try {
-        labels = cb(change);
+        const r = cb(change);
+        if (hasOwnProperty(r, 'labels')) {
+          review = r as ReviewInput;
+        } else {
+          review = {labels: r as LabelNameToValuesMap};
+        }
       } catch (err) {
         this.reporting.error(err);
       }
     }
-    return labels;
+    return review;
   }
 
   _getEventCallbacks(type: EventType) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index ce57f92..d5d734a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -283,18 +283,33 @@
     assert.isTrue(errorStub.calledTwice);
   });
 
-  test('postrevert event', () => {
+  test('postrevert event labels', () => {
     function getLabels(c) {
       return {'Code-Review': 1};
     }
 
-    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+    assert.deepEqual(element.getReviewPostRevert(null), {});
     assert.equal(errorStub.callCount, 0);
 
     plugin.on(EventType.POST_REVERT, throwErrFn);
     plugin.on(EventType.POST_REVERT, getLabels);
     assert.deepEqual(
-        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('postrevert event review', () => {
+    function getReview(c) {
+      return {labels: {'Code-Review': 1}};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(null), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getReview);
+    assert.deepEqual(
+        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
     assert.isTrue(errorStub.calledOnce);
   });
 
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 15294f4..0d751ab 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -49,6 +49,7 @@
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
     // (include and exclude arrays are overriden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 6365bf0..46bd926 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -10,6 +10,7 @@
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
     // (include and exclude arrays are overriden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index efd2978..1b6c226 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -15,6 +15,7 @@
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_test.json
     // (include and exclude arrays are overriden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
new file mode 100644
index 0000000..c3b38c6
--- /dev/null
+++ b/polygerrit-ui/app/types/diff.ts
@@ -0,0 +1,110 @@
+/**
+ * @fileoverview Types related to diffing.
+ *
+ * As gr-diff is an embeddable component, many of these types are actually
+ * defined in api/diff.ts. This file re-exports them and adds any
+ * internal fields that Gerrit may use.
+ *
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+export {
+  ChangeType,
+  MoveDetails,
+  SkipLength,
+  MarkLength,
+  DiffIntralineInfo,
+  IgnoreWhitespaceType,
+} from '../api/diff';
+
+import {
+  DiffInfo as DiffInfoApi,
+  DiffFileMetaInfo as DiffFileMetaInfoApi,
+  DiffContent as DiffContentApi,
+  DiffPreferencesInfo as DiffPreferenceInfoApi,
+} from '../api/diff';
+
+export interface DiffInfo extends DiffInfoApi {
+  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
+  meta_a: DiffFileMetaInfo;
+  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
+  meta_b: DiffFileMetaInfo;
+
+  /** A list of strings representing the patch set diff header. */
+  diff_header?: string[];
+
+  /**
+   * Links to the file diff in external sites as a list of DiffWebLinkInfo
+   * entries.
+   */
+  web_links?: DiffWebLinkInfo[];
+}
+
+/**
+ * The DiffWebLinkInfo entity describes a link on a diff screen to an external
+ * site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ */
+export declare interface DiffWebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+  // TODO: Are these really of type string? Not able to trigger them, but the
+  // docs sound more like boolean.
+  show_on_side_by_side_diff_view: string;
+  show_on_unified_diff_view: string;
+}
+
+export interface DiffFileMetaInfo extends DiffFileMetaInfoApi {
+  /** Links to the file in external sites as a list of WebLinkInfo entries. */
+  web_links?: WebLinkInfo[];
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export declare interface WebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+}
+
+export interface DiffContent extends DiffContentApi {
+  // TODO: Undocumented, but used in code.
+  keyLocation?: boolean;
+}
+
+export interface DiffPreferencesInfo extends DiffPreferenceInfoApi {
+  expand_all_comments?: boolean;
+  cursor_blink_rate: number;
+  manual_review?: boolean;
+  retain_header?: boolean;
+  skip_deleted?: boolean;
+  hide_top_menu?: boolean;
+  hide_line_numbers?: boolean;
+  hide_empty_pane?: boolean;
+  match_brackets?: boolean;
+  line_wrapping?: boolean;
+}
+
+export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index a838f8f..a7e654c 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -48,31 +48,7 @@
   commit: CommitId;
 }
 
-export interface CoverageRange {
-  type: CoverageType;
-  side: Side;
-  code_range: {end_line: number; start_line: number};
-}
-
-export enum CoverageType {
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  COVERED = 'COVERED',
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  NOT_COVERED = 'NOT_COVERED',
-  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
-  /**
-   * You don't have to use this. If there is no coverage information for a
-   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
-   * end_character of the range will be ignored for this type.
-   */
-  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
-}
+export {CoverageRange, CoverageType} from '../api/diff';
 
 export enum ErrorType {
   AUTH = 'AUTH',
@@ -208,7 +184,7 @@
 }
 
 export interface DashboardViewState {
-  selectedChangeIndex: number;
+  [key: string]: number;
 }
 
 export interface ViewState {
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 5101d11..fad5041 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -39,25 +39,25 @@
 export function fromNow(date: Date, noAgo = false) {
   const now = new Date();
   const ago = noAgo ? '' : ' ago';
-  const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
+  const secondsAgo = Math.floor((now.valueOf() - date.valueOf()) / 1000);
   if (secondsAgo <= 59) return 'just now';
   if (secondsAgo <= 119) return `1 minute${ago}`;
-  const minutesAgo = Math.round(secondsAgo / 60);
+  const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
   if (minutesAgo === 60) return `1 hour${ago}`;
   if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
-  const hoursAgo = Math.round(minutesAgo / 60);
+  const hoursAgo = Math.floor(minutesAgo / 60);
   if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
   if (hoursAgo === 24) return `1 day${ago}`;
   if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
-  const daysAgo = Math.round(hoursAgo / 24);
+  const daysAgo = Math.floor(hoursAgo / 24);
   if (daysAgo <= 30) return `${daysAgo} days${ago}`;
   if (daysAgo <= 60) return `1 month${ago}`;
-  const monthsAgo = Math.round(daysAgo / 30);
+  const monthsAgo = Math.floor(daysAgo / 30);
   if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
   if (monthsAgo === 12) return `1 year${ago}`;
   if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
-  const yearsAgo = Math.round(daysAgo / 365);
+  const yearsAgo = Math.floor(daysAgo / 365);
   return `${yearsAgo} years${ago}`;
 }
 
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index 76d8516..96d5bc1 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -53,6 +53,11 @@
       assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
       assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
     });
+    test('rounding error', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('2 hours ago', fromNow(new Date('May 08 2020 9:30:00')));
+    });
   });
 
   suite('isWithinDay', () => {