Merge "uploadPackAuditEventLog: Avoid commit timestamp mismatch"
diff --git a/.bazelversion b/.bazelversion
index 7c69a55d..fcdb2e1 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.7.0
+4.0.0
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index cd794b8..b7cdf8a 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -925,6 +925,18 @@
 ----
 
 
+[[PublicDomain]]
+PublicDomain
+
+* guice:aopalliance
+
+[[PublicDomain_license]]
+----
+This software has been placed in the public domain by its author(s).
+
+----
+
+
 [[antlr]]
 antlr
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 2a59d0c..c8d58a7 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2816,6 +2816,8 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`disable_keyboard_shortcuts`     |not set if `false`|
+Whether to disable all keyboard shortcuts.
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 265014e..9876b53 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2056,6 +2056,10 @@
 will contain a list of link:#context-line[ContextLine] containing the lines of
 the source file where the comment was written.
 
+The `context-padding` request parameter can be used to specify an extra number
+of context lines to be added before and after the comment range. This parameter
+only works if `enable-context` is set to true.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index a3592b1..677fc6d 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2704,7 +2704,10 @@
 The integer-valued request parameter `parent` changes the response to return a
 list of the files which are different in this commit compared to the given
 parent commit. This is useful for supporting review of merge commits. The value
-is the 1-based index of the parent's position in the commit object.
+is the 1-based index of the parent's position in the commit object. If the
+value 0 is used for `parent`, the default base commit will be used, which is
+the only parent for commits having one parent or the auto-merge commit
+otherwise.
 
 [[dashboard-endpoints]]
 == Dashboard Endpoints
diff --git a/WORKSPACE b/WORKSPACE
index c35c190..0767907 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -66,17 +66,17 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "dd4dc46066e2ce034cba0c81aa3e862b27e8e8d95871f567359f7a534cccb666",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.1.0/rules_nodejs-3.1.0.tar.gz"],
+    sha256 = "fcc6dccb39ca88d481224536eb8f9fa754619676c6163f87aa6af94059b02b12",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.0/rules_nodejs-3.2.0.tar.gz"],
 )
 
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
+    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
     ],
 )
 
@@ -88,10 +88,10 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
     ],
 )
 
@@ -140,6 +140,12 @@
 )
 
 maven_jar(
+    name = "aopalliance",
+    artifact = "aopalliance:aopalliance:1.0",
+    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
+)
+
+maven_jar(
     name = "javax_inject",
     artifact = "javax.inject:javax.inject:1",
     sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
@@ -363,156 +369,156 @@
     sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
 )
 
-FLEXMARK_VERS = "0.34.18"
+FLEXMARK_VERS = "0.50.42"
 
 maven_jar(
     name = "flexmark",
     artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
+    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
 )
 
 maven_jar(
     name = "flexmark-ext-abbreviation",
     artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
+    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
 )
 
 maven_jar(
     name = "flexmark-ext-anchorlink",
     artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6df2e23b5c94a5e46b1956a29179eb783f84ea2f",
+    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
 )
 
 maven_jar(
     name = "flexmark-ext-autolink",
     artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "069f8ff15e5b435cc96b23f31798ce64a7a3f6d3",
+    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
 )
 
 maven_jar(
     name = "flexmark-ext-definition",
     artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "ff177d8970810c05549171e3ce189e2c68b906c0",
+    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
 )
 
 maven_jar(
     name = "flexmark-ext-emoji",
     artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "410bf7d8e5b8bc2c4a8cff644d1b2bc7b271a41e",
+    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
 )
 
 maven_jar(
     name = "flexmark-ext-escaped-character",
     artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6f4fb89311b54284a6175341d4a5e280f13b2179",
+    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
 )
 
 maven_jar(
     name = "flexmark-ext-footnotes",
     artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "35efe7d9aea97b6f36e09c65f748863d14e1cfe4",
+    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-issues",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "ec1d660102f6a1d0fbe5e57c13b7ff8bae6cff72",
+    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-strikethrough",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "6060442b742c9b6d4d83d7dd4f0fe477c4686dd2",
+    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "2fe597849e46e02e0c1ea1d472848f74ff261282",
+    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tasklist",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "b3af19ce4efdc980a066c1bf0f5a6cf8c24c487a",
+    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-users",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7456c5f7272c195ee953a02ebab4f58374fb23ee",
+    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
 )
 
 maven_jar(
     name = "flexmark-ext-ins",
     artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "13fe1a95a8f3be30b574451cfe8d3d5936fa3e94",
+    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
 )
 
 maven_jar(
     name = "flexmark-ext-jekyll-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "e146e2bf3a740d6ef06a33a516c4d1f6d3761109",
+    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
 )
 
 maven_jar(
     name = "flexmark-ext-superscript",
     artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "02541211e8e4a6c89ce0a68b07b656d8a19ac282",
+    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
 )
 
 maven_jar(
     name = "flexmark-ext-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "775d9587de71fd50573f32eee98ab039b4dcc219",
+    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
 )
 
 maven_jar(
     name = "flexmark-ext-toc",
     artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "85b75fe1ebe24c92b9d137bcbc51d232845b6077",
+    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
 )
 
 maven_jar(
     name = "flexmark-ext-typographic",
     artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "c1bf0539de37d83aa05954b442f929e204cd89db",
+    sha1 = "6549b9862b61c4434a855a733237103df9162849",
 )
 
 maven_jar(
     name = "flexmark-ext-wikilink",
     artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "400b23b9a4e0c008af0d779f909ee357628be39d",
+    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
 )
 
 maven_jar(
     name = "flexmark-ext-yaml-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "491f815285a8e16db1e906f3789a94a8a9836fa6",
+    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
 )
 
 maven_jar(
     name = "flexmark-formatter",
     artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "d46308006800d243727100ca0f17e6837070fd48",
+    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
 )
 
 maven_jar(
     name = "flexmark-html-parser",
     artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "fece2e646d11b6a77fc611b4bd3eb1fb8a635c87",
+    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
 )
 
 maven_jar(
     name = "flexmark-profile-pegdown",
     artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "297f723bb51286eaa7029558fac87d819643d577",
+    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
 )
 
 maven_jar(
     name = "flexmark-util",
     artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "31e2e1fbe8273d7c913506eafeb06b1a7badb062",
+    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
 )
 
 # Transitive dependency of flexmark and gitiles
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 1fa099e..ca13db9 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -456,20 +456,14 @@
    */
   protected Timestamp lastUpdatedOn;
 
-  // DELETED: id = 6 (sortkey)
-
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
   protected BranchNameKey dest;
 
-  // DELETED: id = 9 (open)
-
   /** Current state code; see {@link Status}. */
   protected char status;
 
-  // DELETED: id = 11 (nbrPatchSets)
-
   /** The current patch set. */
   protected int currentPatchSetId;
 
@@ -479,9 +473,6 @@
   /** Topic name assigned by the user, if any. */
   @Nullable protected String topic;
 
-  // DELETED: id = 15 (lastSha1MergeTested)
-  // DELETED: id = 16 (mergeable)
-
   /**
    * First line of first patch set's commit message.
    *
@@ -553,12 +544,12 @@
     cherryPickOf = other.cherryPickOf;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public Change.Id getId() {
     return changeId;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public int getChangeId() {
     return changeId.get();
   }
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 522c60a..4272960 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -139,6 +139,11 @@
     return ref;
   }
 
+  /**
+   * Warning: Change refs have to manually be advertised in {@code
+   * com.google.gerrit.server.permissions.DefaultRefFilter}; this should be done when adding new
+   * change refs.
+   */
   public static String changeMetaRef(Change.Id id) {
     StringBuilder r = newStringBuilder().append(REFS_CHANGES);
     return shard(id.get(), r).append(META_SUFFIX).toString();
@@ -175,6 +180,16 @@
     return ref.startsWith(REFS_CHANGES);
   }
 
+  /** True if the provided ref is in {@code refs/changes/../meta}. */
+  public static boolean isRefsMetaChanges(String ref) {
+    return ref.startsWith(REFS_CHANGES) && ref.endsWith(META_SUFFIX);
+  }
+
+  /** True if the provided ref is in {@code refs/changes/../robot-comments}. */
+  public static boolean isRobotCommentMetaRef(String ref) {
+    return ref.startsWith(REFS_CHANGES) && ref.endsWith(ROBOT_COMMENTS_SUFFIX);
+  }
+
   public static String refsGroups(AccountGroup.UUID groupUuid) {
     return REFS_GROUPS + shardUuid(groupUuid.get());
   }
diff --git a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
index 9f74e9d..452192c 100644
--- a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
+++ b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.exceptions;
 
 public class InternalServerWithUserMessageException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
   public InternalServerWithUserMessageException(String msg, Throwable cause) {
     super(msg, cause);
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 3364fc1..6c15c0c 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -413,6 +413,7 @@
 
   abstract class CommentsRequest {
     private boolean enableContext;
+    private int contextPadding;
 
     /**
      * Get all published comments on a change.
@@ -436,6 +437,11 @@
       return this;
     }
 
+    public CommentsRequest contextPadding(int contextPadding) {
+      this.contextPadding = contextPadding;
+      return this;
+    }
+
     public CommentsRequest withContext() {
       this.enableContext = true;
       return this;
@@ -444,6 +450,10 @@
     public boolean getContext() {
       return enableContext;
     }
+
+    public int getContextPadding() {
+      return contextPadding;
+    }
   }
 
   abstract class SuggestedReviewersRequest {
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
index a53fc74..b0cc9da 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -18,8 +18,10 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Map;
 
 public interface CommitApi {
   CommitInfo get() throws RestApiException;
@@ -28,6 +30,9 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
+  /** List files in a specific commit against the parent commit. */
+  Map<String, FileInfo> files(int parentNum) throws RestApiException;
+
   /** A default implementation for source compatibility when adding new methods to the interface. */
   class NotImplemented implements CommitApi {
     @Override
@@ -44,5 +49,10 @@
     public IncludedInInfo includedIn() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 4cb52b7..21b319e 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -147,6 +147,7 @@
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
+  public Boolean disableKeyboardShortcuts;
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
@@ -205,6 +206,7 @@
     p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
+    p.disableKeyboardShortcuts = false;
     p.workInProgressByDefault = false;
     return p;
   }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 8893039..8047e0e 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -612,6 +612,7 @@
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.apply(change).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -623,6 +624,7 @@
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.getComments(change);
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index 5c7921a..e055a00 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -22,13 +22,16 @@
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
 import com.google.gerrit.server.restapi.project.CommitIncludedIn;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
 import com.google.gerrit.server.restapi.project.GetCommit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
 
 public class CommitApiImpl implements CommitApi {
   public interface Factory {
@@ -40,6 +43,7 @@
   private final CherryPickCommit cherryPickCommit;
   private final CommitIncludedIn includedIn;
   private final CommitResource commitResource;
+  private final FilesInCommitCollection.ListFiles listFiles;
 
   @Inject
   CommitApiImpl(
@@ -47,11 +51,13 @@
       GetCommit getCommit,
       CherryPickCommit cherryPickCommit,
       CommitIncludedIn includedIn,
+      FilesInCommitCollection.ListFiles listFiles,
       @Assisted CommitResource commitResource) {
     this.changes = changes;
     this.getCommit = getCommit;
     this.cherryPickCommit = cherryPickCommit;
     this.includedIn = includedIn;
+    this.listFiles = listFiles;
     this.commitResource = commitResource;
   }
 
@@ -81,4 +87,13 @@
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
+
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return listFiles.setParent(parentNum).apply(commitResource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index 03ce318..ad6f9c7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -69,7 +69,8 @@
   /**
    * Computes the list of modified files for a given project and commit against its parent. For
    * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
-   * 0.
+   * 0. A value of -1 for parent can be passed to use the default base commit, which is the only
+   * parent for commits having only one parent, or the auto-merge otherwise.
    *
    * @param project a project identifying a repository.
    * @param objectId a commit SHA-1 identifying a patchset commit.
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 0dd71507..ed046826 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -48,9 +48,8 @@
       if (base == null) {
         return asFileInfo(
             diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
-      } else {
-        return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
       }
+      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
       convertException(e);
       return null; // unreachable. handleAndThrow will throw an exception anyway
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
index 2ac7a87..55d162a 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -60,8 +60,10 @@
       Project.NameKey project, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
     PatchListKey key =
-        PatchListKey.againstParentNum(
-            parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+        parentNum == -1
+            ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
+            : PatchListKey.againstParentNum(
+                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     return toFileInfoMap(project, key);
   }
 
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a548262..b43996e 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -219,8 +219,13 @@
             .setFireRevisionCreated(fireRevisionCreated)
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
-            .setSendEmail(sendEmail)
-            .setWorkInProgress(!rebasedCommit.getFilesWithGitConflicts().isEmpty());
+            .setSendEmail(sendEmail);
+
+    if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
+        && !notes.getChange().isWorkInProgress()) {
+      patchSetInserter.setWorkInProgress(true);
+    }
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index a0ab398..3d75349 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.Weigher;
@@ -23,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -36,6 +38,7 @@
 import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
 import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.comment.CommentContextLoader.ContextInput;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -50,14 +53,22 @@
 
 /** Implementation of {@link CommentContextCache}. */
 public class CommentContextCacheImpl implements CommentContextCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final String CACHE_NAME = "comment_context";
 
+  /**
+   * Comment context is expected to contain just few lines of code to be displayed beside the
+   * comment. Setting an upper bound of 100 for padding.
+   */
+  @VisibleForTesting public static final int MAX_CONTEXT_PADDING = 50;
+
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
-            .version(1)
+            .version(2)
             .diskLimit(1 << 30) // limit the total cache size to 1 GB
             .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
             .weigher(CommentContextWeigher.class)
@@ -88,9 +99,14 @@
       Iterable<CommentContextKey> inputKeys) {
     ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
 
+    List<CommentContextKey> adjustedKeys =
+        Streams.stream(inputKeys)
+            .map(CommentContextCacheImpl::adjustMaxContextPadding)
+            .collect(ImmutableList.toImmutableList());
+
     // Convert the input keys to the same keys but with their file paths hashed
     Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
-        Streams.stream(inputKeys)
+        adjustedKeys.stream()
             .collect(
                 Collectors.toMap(
                     Function.identity(),
@@ -101,7 +117,7 @@
           contextCache.getAll(keysToCacheKeys.values());
 
       for (CommentContextKey inputKey : inputKeys) {
-        CommentContextKey cacheKey = keysToCacheKeys.get(inputKey);
+        CommentContextKey cacheKey = keysToCacheKeys.get(adjustMaxContextPadding(inputKey));
         result.put(inputKey, allContext.get(cacheKey));
       }
       return result.build();
@@ -110,6 +126,23 @@
     }
   }
 
+  private static CommentContextKey adjustMaxContextPadding(CommentContextKey key) {
+    if (key.contextPadding() < 0) {
+      logger.atWarning().log(
+          "Cannot set context padding to a negative number %d. Adjusting the number to 0",
+          key.contextPadding());
+      return key.toBuilder().contextPadding(0).build();
+    }
+    if (key.contextPadding() > MAX_CONTEXT_PADDING) {
+      logger.atWarning().log(
+          "Number of requested context lines is %d and exceeding the configured maximum of %d."
+              + " Adjusting the number to the maximum.",
+          key.contextPadding(), MAX_CONTEXT_PADDING);
+      return key.toBuilder().contextPadding(MAX_CONTEXT_PADDING).build();
+    }
+    return key;
+  }
+
   public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
     INSTANCE;
 
@@ -216,11 +249,12 @@
       ChangeNotes notes = notesFactory.createChecked(project, changeId);
       List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
       CommentContextLoader loader = factory.create(project);
-      Map<Comment, CommentContextKey> commentsToKeys = new HashMap<>();
+      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
       for (CommentContextKey key : keys) {
-        commentsToKeys.put(getCommentForKey(humanComments, key), key);
+        Comment comment = getCommentForKey(humanComments, key);
+        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
       }
-      Map<Comment, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
       return allContext.entrySet().stream()
           .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
     }
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
index ccd50b7..af2ae92 100644
--- a/java/com/google/gerrit/server/comment/CommentContextKey.java
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -28,6 +28,9 @@
 
   abstract Integer patchset();
 
+  /** Number of extra lines of context that should be added before and after the comment range. */
+  abstract int contextPadding();
+
   abstract Builder toBuilder();
 
   public static Builder builder() {
@@ -47,6 +50,8 @@
 
     public abstract Builder patchset(Integer patchset);
 
+    public abstract Builder contextPadding(Integer numLines);
+
     public abstract CommentContextKey build();
   }
 
@@ -62,6 +67,7 @@
               .setPatchset(key.patchset())
               .setPathHash(key.path())
               .setCommentId(key.id())
+              .setContextPadding(key.contextPadding())
               .build());
     }
 
@@ -75,6 +81,7 @@
           .patchset(proto.getPatchset())
           .id(proto.getCommentId())
           .path(proto.getPathHash())
+          .contextPadding(proto.getContextPadding())
           .build();
     }
   }
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index f642438..63bb8d0 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -21,8 +21,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.Project;
@@ -34,6 +34,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -66,43 +67,51 @@
   }
 
   /**
-   * Load the comment context for multiple comments at once. This method will open the repository
-   * and read the source files for all necessary comments' file paths.
+   * Load the comment context for multiple contextInputs at once. This method will open the
+   * repository and read the source files for all necessary contextInputs' file paths.
    *
-   * @param comments a list of comments.
-   * @return a Map where all entries consist of the input comments and the values are their
+   * @param contextInputs a list of contextInputs.
+   * @return a Map where all entries consist of the input contextInputs and the values are their
    *     corresponding {@link CommentContext}.
    */
-  public Map<Comment, CommentContext> getContext(Iterable<Comment> comments) throws IOException {
-    ImmutableMap.Builder<Comment, CommentContext> result =
-        ImmutableMap.builderWithExpectedSize(Iterables.size(comments));
+  public Map<ContextInput, CommentContext> getContext(Collection<ContextInput> contextInputs)
+      throws IOException {
+    ImmutableMap.Builder<ContextInput, CommentContext> result =
+        ImmutableMap.builderWithExpectedSize(Iterables.size(contextInputs));
 
-    // Group comments by commit ID so that each commit is parsed only once
-    Map<ObjectId, List<Comment>> commentsByCommitId =
-        Streams.stream(comments).collect(groupingBy(Comment::getCommitId));
+    // Group contextInputs by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextInput>> commentsByCommitId =
+        contextInputs.stream().collect(groupingBy(ContextInput::commitId));
 
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       for (ObjectId commitId : commentsByCommitId.keySet()) {
         RevCommit commit = rw.parseCommit(commitId);
-        for (Comment comment : commentsByCommitId.get(commitId)) {
-          Optional<Range> range = getStartAndEndLines(comment);
+        for (ContextInput contextInput : commentsByCommitId.get(commitId)) {
+          Optional<Range> range = getStartAndEndLines(contextInput);
           if (!range.isPresent()) {
-            result.put(comment, CommentContext.empty());
+            result.put(contextInput, CommentContext.empty());
             continue;
           }
-          String filePath = comment.key.filename;
+          String filePath = contextInput.filePath();
           switch (filePath) {
             case COMMIT_MSG:
               result.put(
-                  comment, getContextForCommitMessage(rw.getObjectReader(), commit, range.get()));
+                  contextInput,
+                  getContextForCommitMessage(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
               break;
             case MERGE_LIST:
               result.put(
-                  comment, getContextForMergeList(rw.getObjectReader(), commit, range.get()));
+                  contextInput,
+                  getContextForMergeList(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
               break;
             default:
-              result.put(comment, getContextForFilePath(repo, rw, commit, filePath, range.get()));
+              result.put(
+                  contextInput,
+                  getContextForFilePath(
+                      repo, rw, commit, filePath, range.get(), contextInput.contextPadding()));
           }
         }
       }
@@ -111,20 +120,27 @@
   }
 
   private CommentContext getContextForCommitMessage(
-      ObjectReader reader, RevCommit commit, Range range) throws IOException {
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
     Text text = Text.forCommit(reader, commit);
-    return createContext(text, range);
+    return createContext(text, commentRange, contextPadding);
   }
 
-  private CommentContext getContextForMergeList(ObjectReader reader, RevCommit commit, Range range)
+  private CommentContext getContextForMergeList(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
       throws IOException {
     ComparisonType cmp = ComparisonType.againstParent(1);
     Text text = Text.forMergeList(cmp, reader, commit);
-    return createContext(text, range);
+    return createContext(text, commentRange, contextPadding);
   }
 
   private CommentContext getContextForFilePath(
-      Repository repo, RevWalk rw, RevCommit commit, String filePath, Range range)
+      Repository repo,
+      RevWalk rw,
+      RevCommit commit,
+      String filePath,
+      Range commentRange,
+      int contextPadding)
       throws IOException {
     // TODO(ghareeb): We can further group the comments by file paths to avoid opening
     // the same file multiple times.
@@ -136,28 +152,43 @@
       }
       ObjectId id = tw.getObjectId(0);
       Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-      return createContext(src, range);
+      return createContext(src, commentRange, contextPadding);
     }
   }
 
-  private static CommentContext createContext(Text src, Range range) {
-    if (range.start() < 1 || range.end() > src.size()) {
+  private static CommentContext createContext(Text src, Range commentRange, int contextPadding) {
+    if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
       throw new StorageException(
-          "Invalid comment range " + range + ". Text only contains " + src.size() + " lines.");
+          "Invalid comment range "
+              + commentRange
+              + ". Text only contains "
+              + src.size()
+              + " lines.");
     }
+    commentRange = adjustRange(commentRange, contextPadding, src.size());
     ImmutableMap.Builder<Integer, String> context =
-        ImmutableMap.builderWithExpectedSize(range.end() - range.start());
-    for (int i = range.start(); i < range.end(); i++) {
+        ImmutableMap.builderWithExpectedSize(commentRange.end() - commentRange.start());
+    for (int i = commentRange.start(); i < commentRange.end(); i++) {
       context.put(i, src.getString(i - 1));
     }
     return CommentContext.create(context.build());
   }
 
-  private static Optional<Range> getStartAndEndLines(Comment comment) {
-    if (comment.range != null) {
-      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine + 1));
-    } else if (comment.lineNbr > 0) {
-      return Optional.of(Range.create(comment.lineNbr, comment.lineNbr + 1));
+  /**
+   * Adjust the {@code commentRange} parameter by adding {@code contextPadding} lines before and
+   * after the comment range.
+   */
+  private static Range adjustRange(Range commentRange, int contextPadding, int fileLines) {
+    int newStartLine = commentRange.start() - contextPadding;
+    int newEndLine = commentRange.end() + contextPadding;
+    return Range.create(Math.max(1, newStartLine), Math.min(fileLines + 1, newEndLine));
+  }
+
+  private static Optional<Range> getStartAndEndLines(ContextInput comment) {
+    if (comment.range() != null) {
+      return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
+    } else if (comment.lineNumber() > 0) {
+      return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
     }
     return Optional.empty();
   }
@@ -173,5 +204,62 @@
 
     /** End line of the comment (exclusive). */
     abstract int end();
+
+    /** Number of lines covered by this range. */
+    int size() {
+      return end() - start();
+    }
+  }
+
+  /** This entity only contains comment fields needed to load the comment context. */
+  @AutoValue
+  abstract static class ContextInput {
+    static ContextInput fromComment(Comment comment, int contextPadding) {
+      return new AutoValue_CommentContextLoader_ContextInput.Builder()
+          .commitId(comment.getCommitId())
+          .filePath(comment.key.filename)
+          .range(comment.range)
+          .lineNumber(comment.lineNbr)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
+    /** 20 bytes SHA-1 of the patchset commit containing the file where the comment is written. */
+    abstract ObjectId commitId();
+
+    /** File path where the comment is written. */
+    abstract String filePath();
+
+    /**
+     * Position of the comment in the file (start line, start char, end line, end char). This field
+     * can be null if the range is not available for this comment.
+     */
+    @Nullable
+    abstract Comment.Range range();
+
+    /**
+     * The 1-based line number where the comment is written. A value 0 means that the line number is
+     * not available for this comment.
+     */
+    abstract Integer lineNumber();
+
+    /** Number of extra lines of context that should be added before and after the comment range. */
+    abstract Integer contextPadding();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder commitId(ObjectId commitId);
+
+      public abstract Builder filePath(String filePath);
+
+      public abstract Builder range(@Nullable Comment.Range range);
+
+      public abstract Builder lineNumber(Integer lineNumber);
+
+      public abstract Builder contextPadding(Integer contextPadding);
+
+      public abstract ContextInput build();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 2d5e708..d71f83e 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,14 +21,14 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.vladsch.flexmark.ast.Block;
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
 import com.vladsch.flexmark.parser.Parser;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Block;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
index 00f7ec1..1875b64 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.documentation;
 
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ext.anchorlink.AnchorLink;
 import com.vladsch.flexmark.ext.anchorlink.internal.AnchorLinkNodeRenderer;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -28,8 +27,9 @@
 import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
 import com.vladsch.flexmark.profiles.pegdown.Extensions;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.DataHolder;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.DataHolder;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -119,7 +119,7 @@
 
     public static class Factory implements DelegatingNodeRendererFactory {
       @Override
-      public NodeRenderer create(final DataHolder options) {
+      public NodeRenderer apply(final DataHolder options) {
         return new HeadingNodeRenderer();
       }
 
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index fed6541..c3d4777 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -17,10 +17,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -42,6 +44,7 @@
 import com.google.inject.name.Named;
 import com.google.inject.util.Providers;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
@@ -96,6 +99,10 @@
 
     @Nullable
     abstract ReviewerSet reviewers();
+
+    abstract Collection<PatchSet> patchSets();
+
+    abstract ImmutableList<byte[]> refStates();
   }
 
   private final LoadingCache<Project.NameKey, List<CachedChange>> cache;
@@ -125,6 +132,8 @@
       for (CachedChange cc : cached) {
         ChangeData cd = changeDataFactory.create(cc.change());
         cd.setReviewers(cc.reviewers());
+        cd.setPatchSets(cc.patchSets());
+        cd.setRefStates(cc.refStates());
         cds.add(cd);
       }
       return Collections.unmodifiableList(cds);
@@ -160,12 +169,17 @@
         List<ChangeData> cds =
             queryProvider
                 .get()
-                .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
+                .setRequestedFields(
+                    ChangeField.CHANGE,
+                    ChangeField.REVIEWER,
+                    ChangeField.REF_STATE,
+                    ChangeField.PATCH_SET)
                 .byProject(key);
         List<CachedChange> result = new ArrayList<>(cds.size());
         for (ChangeData cd : cds) {
           result.add(
-              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
+              new AutoValue_SearchingChangeCacheImpl_CachedChange(
+                  cd.change(), cd.reviewers(), cd.patchSets(), cd.getRefStates()));
         }
         return Collections.unmodifiableList(result);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index e8f1fe1..cf09ff3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -512,6 +512,7 @@
   }
 
   public RobotCommentNotes getRobotCommentNotes() {
+    loadRobotComments();
     return robotCommentNotes;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index af8c8c8..33bc039 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -65,8 +64,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -455,6 +452,9 @@
     abstract ChangeNotesState build();
   }
 
+  /**
+   * Convert ChangeNotesState (which is AutoValue based) to byte[] and back, using protocol buffers.
+   */
   enum Serializer implements CacheSerializer<ChangeNotesState> {
     INSTANCE;
 
@@ -482,13 +482,11 @@
       object.hashtags().forEach(b::addHashtag);
       object
           .patchSets()
-          .forEach(e -> b.addPatchSet(toByteString(e.getValue(), PatchSetProtoConverter.INSTANCE)));
+          .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
       object
           .approvals()
           .forEach(
-              e ->
-                  b.addApproval(
-                      toByteString(e.getValue(), PatchSetApprovalProtoConverter.INSTANCE)));
+              e -> b.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(e.getValue())));
 
       object.reviewers().asTable().cellSet().forEach(c -> b.addReviewer(toReviewerSetEntry(c)));
       object
@@ -519,7 +517,7 @@
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
       object
           .changeMessages()
-          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
+          .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
@@ -530,12 +528,6 @@
       return Protos.toByteArray(b.build());
     }
 
-    @VisibleForTesting
-    static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
-      MessageLite message = converter.toProto(object);
-      return Protos.toByteString(message);
-    }
-
     private static ChangeColumnsProto toChangeColumnsProto(ChangeColumns cols) {
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
@@ -635,12 +627,12 @@
               .hashtags(proto.getHashtagList())
               .patchSets(
                   proto.getPatchSetList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
                       .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto.getApprovalList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetApprovalProtoConverter.INSTANCE.fromProto(msg))
                       .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
@@ -660,7 +652,7 @@
                       .collect(toImmutableList()))
               .changeMessages(
                   proto.getChangeMessageList().stream()
-                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
+                      .map(msg -> ChangeMessageProtoConverter.INSTANCE.fromProto(msg))
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
@@ -671,12 +663,6 @@
       return b.build();
     }
 
-    private static <P extends MessageLite, T> T parseProtoFrom(
-        ProtoConverter<P, T> converter, ByteString byteString) {
-      P message = Protos.parseUnchecked(converter.getParser(), byteString);
-      return converter.fromProto(message);
-    }
-
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index d20cfe4..d945da3 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.patch.filediff;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
@@ -216,7 +217,7 @@
             : createMergeListEntry(
                 reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
       } catch (IOException e) {
-        logger.atWarning().log("Failed to compute commit entry for key " + key);
+        logger.atWarning().log("Failed to compute commit entry for key %s", key);
       }
       return FileDiffOutput.empty(key.newFilePath());
     }
@@ -245,7 +246,7 @@
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
-      Text aText = newCommit != null ? Text.forCommit(reader, newCommit) : Text.EMPTY;
+      Text aText = oldCommit != null ? Text.forCommit(reader, oldCommit) : Text.EMPTY;
       Text bText = Text.forCommit(reader, newCommit);
       return createMagicFileDiffOutput(
           rawTextComparator, oldCommit, aText, bText, Patch.COMMIT_MSG, diffAlgorithm);
@@ -457,9 +458,7 @@
           new EditTransformer(
               ImmutableList.of(
                   FileEdits.create(
-                      parentVsParentDiff.edits().stream()
-                          .map(Edit::toJGitEdit)
-                          .collect(Collectors.toList()),
+                      parentVsParentDiff.edits().stream().collect(toImmutableList()),
                       parentVsParentDiff.oldPath(),
                       parentVsParentDiff.newPath())));
 
@@ -468,9 +467,7 @@
         editTransformer.transformReferencesOfSideA(
             ImmutableList.of(
                 FileEdits.create(
-                    oldVsParDiff.edits().stream()
-                        .map(Edit::toJGitEdit)
-                        .collect(Collectors.toList()),
+                    oldVsParDiff.edits().stream().collect(toImmutableList()),
                     oldVsParDiff.oldPath(),
                     oldVsParDiff.newPath())));
       }
@@ -480,9 +477,7 @@
         editTransformer.transformReferencesOfSideB(
             ImmutableList.of(
                 FileEdits.create(
-                    newVsParDiff.edits().stream()
-                        .map(Edit::toJGitEdit)
-                        .collect(Collectors.toList()),
+                    newVsParDiff.edits().stream().collect(toImmutableList()),
                     newVsParDiff.oldPath(),
                     newVsParDiff.newPath())));
       }
@@ -501,7 +496,8 @@
               .map(ContextAwareEdit::toEdit)
               .filter(Optional::isPresent)
               .map(Optional::get)
-              .collect(Collectors.toList()),
+              .map(Edit::fromJGitEdit)
+              .collect(toImmutableList()),
           edits.iterator().next().getOldFilePath(),
           edits.iterator().next().getNewFilePath());
     }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
index 376bbc2..a009a02 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileEdits.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -18,7 +18,6 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
-import java.util.List;
 import java.util.Optional;
 
 /**
@@ -28,12 +27,16 @@
 @AutoValue
 public abstract class FileEdits {
   public static FileEdits create(
-      List<org.eclipse.jgit.diff.Edit> jgitEdits,
+      ImmutableList<Edit> edits, Optional<String> oldPath, Optional<String> newPath) {
+    return new AutoValue_FileEdits(edits, oldPath, newPath);
+  }
+
+  public static FileEdits createFromJgitEdits(
+      ImmutableList<org.eclipse.jgit.diff.Edit> jgitEdits,
       Optional<String> oldPath,
       Optional<String> newPath) {
-    ImmutableList<Edit> edits =
-        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList());
-    return new AutoValue_FileEdits(edits, oldPath, newPath);
+    return new AutoValue_FileEdits(
+        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList()), oldPath, newPath);
   }
 
   public abstract ImmutableList<Edit> edits();
diff --git a/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
index 5b7eddb..d1c0b45 100644
--- a/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
@@ -348,7 +348,7 @@
         newName = Optional.of(patchListEntry.getNewName());
         break;
     }
-    return FileEdits.create(patchListEntry.getEdits(), oldName, newName);
+    return FileEdits.createFromJgitEdits(patchListEntry.getEdits(), oldName, newName);
   }
 
   private static boolean isRootOrMergeCommit(RevCommit commit) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index bc859f3..88f91be 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -189,28 +189,43 @@
     }
 
     if (opts.returnMostRecentRefChanges()) {
-      visibleChangesCache.cachedVisibleChanges().values().stream()
-          .forEach(n -> addAllChangeAndPatchsetRefs(visibleRefs, n));
+      visibleChangesCache.cachedVisibleChanges().keySet().stream()
+          .forEach(c -> addAllChangeAndPatchsetRefs(visibleRefs, c));
     }
 
     logger.atFinest().log("visible refs = %s", visibleRefs);
     return visibleRefs;
   }
 
-  private void addAllChangeAndPatchsetRefs(Collection<Ref> refs, ChangeNotes changeNotes) {
-    refs.add(
-        new ObjectIdRef.PeeledNonTag(
-            Storage.PACKED,
-            RefNames.changeMetaRef(changeNotes.getChangeId()),
-            changeNotes.getMetaId()));
-    changeNotes
-        .getPatchSets()
-        .values()
-        .forEach(
-            p ->
-                refs.add(
-                    new ObjectIdRef.PeeledNonTag(
-                        Storage.PACKED, RefNames.patchSetRef(p.id()), p.commitId())));
+  private void addAllChangeAndPatchsetRefs(Collection<Ref> refs, Change.Id changeId) {
+    try {
+      refs.add(
+          new ObjectIdRef.PeeledNonTag(
+              Storage.PACKED,
+              RefNames.changeMetaRef(changeId),
+              visibleChangesCache.getMetaId(changeId)));
+      visibleChangesCache
+          .getPatchSets(changeId)
+          .forEach(
+              p ->
+                  refs.add(
+                      new ObjectIdRef.PeeledNonTag(
+                          Storage.PACKED, RefNames.patchSetRef(p.id()), p.commitId())));
+      if (visibleChangesCache.getRobotCommentsMetaId(changeId) != null) {
+        refs.add(
+            new ObjectIdRef.PeeledNonTag(
+                Storage.PACKED,
+                RefNames.robotCommentsRef(changeId),
+                visibleChangesCache.getRobotCommentsMetaId(changeId)));
+      }
+    } catch (PermissionBackendException ex) {
+      // this can't happen since the changes are all already in the cache, so we don't need to
+      // perform permission checks at all to get those changes.
+      throw new IllegalStateException(
+          "this shouldn't happen since all permission checks should have been "
+              + "done previously, exception: %s",
+          ex);
+    }
   }
 
   /**
@@ -318,7 +333,7 @@
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
                       && !r.isSymbolic()
                       && !r.getName().equals(RefNames.REFS_CONFIG))
-          .collect(toCollection(() -> new ArrayList<>(allRefs.size())));
+          .collect(Collectors.toList());
     } catch (IOException e) {
       throw new PermissionBackendException(e);
     }
@@ -351,7 +366,7 @@
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChangesCache.cachedVisibleChanges().get(id).getChange().getDest().branch())
+            .ref(visibleChangesCache.getChange(id).getDest().branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
         logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
index 6284442..926dde6 100644
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
@@ -16,14 +16,18 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
@@ -32,9 +36,10 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.Collections;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -54,7 +59,10 @@
   private final PermissionBackend.ForProject permissionBackendForProject;
 
   private final Repository repository;
-  private Map<Change.Id, ChangeNotes> visibleChanges;
+  private Map<Change.Id, Change> visibleChanges;
+  private Map<Change.Id, ObjectId> metaIds = new HashMap<>();
+  private Map<Change.Id, ObjectId> robotCommentsMetaIds = new HashMap<>();
+  private Multimap<Change.Id, PatchSet> patchSets = HashMultimap.create();
 
   @Inject
   VisibleChangesCache(
@@ -76,49 +84,103 @@
    * by looking at the cached visible changes.
    */
   public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
-    return cachedVisibleChanges().containsKey(changeId);
+    cachedVisibleChanges();
+    return visibleChanges.containsKey(changeId);
   }
 
   /**
    * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
    * changes and caches them.
    */
-  public Map<Change.Id, ChangeNotes> cachedVisibleChanges() throws PermissionBackendException {
+  public Map<Change.Id, Change> cachedVisibleChanges() throws PermissionBackendException {
     if (visibleChanges == null) {
       if (changeCache == null) {
-        visibleChanges = visibleChangesByScan();
+        visibleChangesByScan();
       } else {
-        visibleChanges = visibleChangesBySearch();
+        visibleChangesBySearch();
       }
       logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
     }
     return visibleChanges;
   }
 
-  private Map<Change.Id, ChangeNotes> visibleChangesBySearch() throws PermissionBackendException {
+  /**
+   * Returns the change for {@code changeId}. If not cached, computes *all* visible changes and
+   * caches them before returning this specific change. If not visible or not found, returns null.
+   */
+  @Nullable
+  public Change getChange(Change.Id changeId) throws PermissionBackendException {
+    return cachedVisibleChanges().get(changeId);
+  }
+
+  /**
+   * Returns the change's meta id for {@code changeId}. If not cached, computes *all* visible
+   * changes and caches them before returning this specific meta id. If not visible or not found,
+   * returns null.
+   */
+  @Nullable
+  public ObjectId getMetaId(Change.Id changeId) throws PermissionBackendException {
+    cachedVisibleChanges();
+    return metaIds.get(changeId);
+  }
+
+  /**
+   * Returns the change's robot comment meta id for {@code changeId}. If not cached, computes *all*
+   * visible changes and caches them before returning this specific robot comments meta id. If not
+   * visible, not found or there are no robot comments on this change, returns null.
+   */
+  @Nullable
+  public ObjectId getRobotCommentsMetaId(Change.Id changeId) throws PermissionBackendException {
+    cachedVisibleChanges();
+    return robotCommentsMetaIds.get(changeId);
+  }
+
+  /**
+   * Returns the change's patch-sets for {@code changeId}. If not cached, computes *all* visible
+   * changes and caches them before returning this collection of patch-sets. If not visible or not
+   * found, returns an empty collection.
+   */
+  public Collection<PatchSet> getPatchSets(Change.Id changeId) throws PermissionBackendException {
+    cachedVisibleChanges();
+    return patchSets.get(changeId);
+  }
+
+  private void visibleChangesBySearch() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
     Project.NameKey project = projectState.getNameKey();
     try {
-      Map<Change.Id, ChangeNotes> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(project)) {
         if (!projectState.statePermitsRead()) {
           continue;
         }
         try {
           permissionBackendForProject.change(cd).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.notes());
+          visibleChanges.put(cd.getId(), cd.change());
+          Collection<RefState> refStates = RefState.parseStates(cd.getRefStates()).values();
+          for (RefState refState : refStates) {
+            if (RefNames.isRobotCommentMetaRef(refState.ref())) {
+              if (!refState.id().equals(ObjectId.zeroId())) {
+                robotCommentsMetaIds.put(cd.getId(), refState.id());
+              }
+            }
+            if (RefNames.isRefsMetaChanges(refState.ref())) {
+              metaIds.put(cd.getId(), refState.id());
+            }
+          }
+          cd.patchSets().stream().forEach(ps -> patchSets.put(cd.getId(), ps));
+
         } catch (AuthException e) {
           // Do nothing.
         }
       }
-      return visibleChanges;
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", project);
-      return Collections.emptyMap();
     }
   }
 
-  private Map<Change.Id, ChangeNotes> visibleChangesByScan() throws PermissionBackendException {
+  private void visibleChangesByScan() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
     Project.NameKey p = projectState.getNameKey();
     ImmutableList<ChangeNotesResult> changes;
     try {
@@ -126,17 +188,22 @@
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", p);
-      return Collections.emptyMap();
+      return;
     }
 
-    Map<Change.Id, ChangeNotes> result = Maps.newHashMapWithExpectedSize(changes.size());
     for (ChangeNotesResult notesResult : changes) {
       ChangeNotes notes = toNotes(notesResult);
       if (notes != null) {
-        result.put(notes.getChangeId(), notes);
+        visibleChanges.put(notes.getChangeId(), notes.getChange());
+        metaIds.put(notes.getChangeId(), notes.getMetaId());
+        if (notes.getRobotCommentNotes() != null
+            && notes.getRobotCommentNotes().getMetaId() != null) {
+          robotCommentsMetaIds.put(notes.getChangeId(), notes.getRobotCommentNotes().getMetaId());
+        }
+        notes.getPatchSets().values().stream()
+            .forEach(ps -> patchSets.put(notes.getChangeId(), ps));
       }
     }
-    return result;
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 02f39ab..77b58c6 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -61,6 +61,7 @@
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
   private boolean fillCommentContext;
+  private int contextPadding;
 
   @Inject
   CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
@@ -83,6 +84,11 @@
     return this;
   }
 
+  CommentJson setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+    return this;
+  }
+
   CommentJson setProjectKey(Project.NameKey project) {
     this.project = project;
     return this;
@@ -184,6 +190,7 @@
           .id(r.id)
           .path(r.path)
           .patchset(r.patchSet)
+          .contextPadding(contextPadding)
           .build();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index fa7c1f5..c90e4fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -42,6 +42,7 @@
   private final CommentsUtil commentsUtil;
 
   private boolean includeContext;
+  private int contextPadding;
 
   /**
    * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
@@ -54,6 +55,16 @@
     this.includeContext = context;
   }
 
+  /**
+   * Optional parameter. Works only if {@link #includeContext} is set to true. If {@link
+   * #contextPadding} is set, the context lines in the response will be padded with {@link
+   * #contextPadding} extra lines before and after the comment range.
+   */
+  @Option(name = "--context-padding")
+  public void setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+  }
+
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
@@ -105,6 +116,7 @@
         .setFillAccounts(true)
         .setFillPatchSet(true)
         .setFillCommentContext(includeContext)
+        .setContextPadding(contextPadding)
         .setProjectKey(rsrc.getProject())
         .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 37318d0..1ed7fd7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,7 +33,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -72,7 +70,6 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -84,8 +81,7 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ProjectCache projectCache) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -95,7 +91,6 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
-    this.urlFormatter = urlFormatter;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 3fcaeb8..7bee2f2 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -87,6 +87,11 @@
       this.fileInfoJson = fileInfoJson;
     }
 
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
+
     @Override
     public Response<Map<String, FileInfo>> apply(CommitResource resource)
         throws ResourceConflictException, PatchListNotAvailableException {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index b27cb9b..7f434ca 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -533,6 +533,9 @@
             // Multiply the timeout by the number of projects we're actually attempting to
             // submit.
             .defaultTimeoutMultiplier(cs.projects().size())
+            // By default, we only retry lock failures. Here it's better to also retry unexpected
+            // runtime exceptions.
+            .retryOn(t -> t instanceof RuntimeException)
             .call();
         submissionExecutor.afterExecutions(orm);
 
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 162f324..c374691 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -562,20 +562,22 @@
      *
      * @param name name
      * @return the {@code OptionHandler} or {@code null}
-     *     <p>Note: this is cut & pasted from the parent class in arg4j, it was private and it
-     *     needed to be exposed.
+     *     <p>Note: this was originally cut & pasted from the parent class in arg4j, it was private
+     *     and it needed to be exposed.
      */
     @SuppressWarnings("rawtypes")
     public OptionHandler findOptionByName(String name) {
       for (OptionHandler h : optionsList) {
-        NamedOptionDef option = (NamedOptionDef) h.option;
-        if (name.equals(option.name())) {
-          return h;
-        }
-        for (String alias : option.aliases()) {
-          if (name.equals(alias)) {
+        if (h.option instanceof NamedOptionDef) {
+          NamedOptionDef option = (NamedOptionDef) h.option;
+          if (name.equals(option.name())) {
             return h;
           }
+          for (String alias : option.aliases()) {
+            if (name.equals(alias)) {
+              return h;
+            }
+          }
         }
       }
       return null;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 1c7b54b..50b7a7c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1360,6 +1360,39 @@
   }
 
   @Test
+  public void rebaseDoesNotAddWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still ready for review after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still work in progress after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+  }
+
+  @Test
   public void rebaseConflict_conflictsAllowed() throws Exception {
     String patchSetSubject = "patch set change";
     String patchSetContent = "patch set content";
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 78be4ab..385780b 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,17 +82,14 @@
         .update();
   }
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus()).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isNotEqualTo(Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 9976fbc..5699a04 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -58,6 +59,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -86,6 +88,7 @@
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private TestCommentHelper testCommentHelper;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
@@ -1597,6 +1600,84 @@
   }
 
   @Test
+  public void advertiseMostRecentRefChangesWithRobotCommentRef() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // user doesn't have refs/* permission.
+    requestScopeOperations.setApiUser(user.id());
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(Patch.COMMIT_MSG);
+    testCommentHelper.addRobotComment(cd1.getId(), input);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      PermissionBackend.ForProject forProject = newFilter(project, admin);
+      assertThat(
+              names(
+                  forProject.filter(
+                      repo.getRefDatabase().getRefs(),
+                      repo,
+                      RefFilterOptions.builder().setReturnMostRecentRefChanges(false).build())))
+          .containsExactlyElementsIn(
+              ImmutableList.of(
+                  "HEAD",
+                  RefNames.changeRefPrefix(cd1.getId()) + "robot-comments",
+                  psRef1,
+                  metaRef1,
+                  psRef2,
+                  metaRef2,
+                  psRef3,
+                  metaRef3,
+                  psRef4,
+                  metaRef4,
+                  "refs/heads/branch",
+                  "refs/heads/master",
+                  "refs/meta/config",
+                  "refs/tags/branch-tag",
+                  "refs/tags/master-tag",
+                  "refs/tags/tree-tag"));
+    }
+  }
+
+  @Test
+  public void advertiseMostRecentRefChangesWithRobotCommentRefWithReturnMostRecentRefChanges()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // user doesn't have refs/* permission.
+    requestScopeOperations.setApiUser(user.id());
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(Patch.COMMIT_MSG);
+    testCommentHelper.addRobotComment(cd1.getId(), input);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      PermissionBackend.ForProject forProject = newFilter(project, admin);
+      assertThat(
+              names(
+                  forProject.filter(
+                      ImmutableList.of(),
+                      repo,
+                      RefFilterOptions.builder().setReturnMostRecentRefChanges(true).build())))
+          .containsExactlyElementsIn(
+              ImmutableList.of(
+                  RefNames.changeRefPrefix(cd1.getId()) + "robot-comments",
+                  psRef1,
+                  metaRef1,
+                  psRef2,
+                  metaRef2,
+                  psRef3,
+                  metaRef3,
+                  psRef4,
+                  metaRef4));
+    }
+  }
+
+  @Test
   public void fetchSingleChangeWithoutIndexAccess() throws Exception {
     PushOneCommit.Result change = createChange();
     String patchSetRef = change.getPatchSetId().toRefName();
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 7f8add8..02916c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -713,7 +712,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void autoRetryWithTrace() throws Exception {
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -723,49 +722,6 @@
       RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
       assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.isLoggingForced).isTrue();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failAlways = true;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(traceSubmitRule)
-            .add(
-                new ExceptionHook() {
-                  @Override
-                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
-                    return true;
-                  }
-                })) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(traceSubmitRule.traceId).isNull();
-      assertThat(traceSubmitRule.isLoggingForced).isFalse();
-    }
-  }
-
-  @Test
-  public void noAutoRetryWithTraceIfDisabled() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failOnce = true;
-    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(traceSubmitRule.traceId).isNull();
       assertThat(traceSubmitRule.isLoggingForced).isFalse();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 9e944a2..7e6a822 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -495,6 +495,24 @@
   }
 
   @Test
+  public void rebaseDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // rebase has no impact on the attention set
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setWorkInProgress();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
new file mode 100644
index 0000000..74ba48e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2021 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.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for {@link FilesInCommitCollection}. */
+public class FilesInCommitIT extends AbstractDaemonTest {
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    addCommit(
+        headCommit,
+        ImmutableMap.of("file_1.txt", "file 1 content", "file_2.txt", "file 2 content"));
+
+    Result result = createEmptyChange();
+    changeId = result.getChangeId();
+  }
+
+  @Test
+  public void listFilesForSingleParentCommit() throws Exception {
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile("a_new_file.txt", RawInputUtil.create("Line 1\nLine 2\nLine 3"));
+    gApi.changes().id(changeId).edit().deleteFile("file_1.txt");
+    gApi.changes().id(changeId).edit().publish();
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base, i.e. the single parent
+    // in this case.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly("/COMMIT_MSG", "a_new_file.txt", "file_1.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstParent1() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Diffing against the first parent.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(1);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstDefaultParent() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base. In this case, the
+    // auto-merge commit.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  private void addModifiedPatchSet(
+      String changeId, String filePath, Function<String, String> contentModification)
+      throws Exception {
+    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
+      String newContent = contentModification.apply(content.asString());
+      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
+    }
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Adjust files of repo", files);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
index 8fa80ce..adfe56d 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -37,6 +37,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -286,6 +287,90 @@
     assertThat(comments.get(0).contextLines).isEmpty();
   }
 
+  @Test
+  public void commentContextWithZeroPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 0, ImmutableList.of(3, 4));
+  }
+
+  @Test
+  public void commentContextWithSmallPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 1, ImmutableList.of(2, 3, 4, 5));
+  }
+
+  @Test
+  public void commentContextWithSmallPaddingAtTheBeginningOfFile() throws Exception {
+    String changeId = createChangeWithComment(1, 2);
+    assertContextLines(changeId, /* contextPadding= */ 2, ImmutableList.of(1, 2, 3, 4));
+  }
+
+  @Test
+  public void commentContextWithPaddingLargerThanFileSize() throws Exception {
+    String changeId = createChangeWithComment(3, 3);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 20,
+        ImmutableList.of(1, 2, 3, 4, 5, 6)); // file only contains six lines.
+  }
+
+  @Test
+  public void commentContextWithLargePaddingReturnsAdjustedMaximumPadding() throws Exception {
+    String changeId = createChangeWithCommentLarge(250, 250);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 300,
+        IntStream.range(200, 301).boxed().collect(ImmutableList.toImmutableList()));
+  }
+
+  private String createChangeWithComment(int startLine, int endLine) throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private String createChangeWithCommentLarge(int startLine, int endLine) throws Exception {
+    StringBuilder largeContent = new StringBuilder();
+    for (int i = 0; i < 1000; i++) {
+      largeContent.append("line " + i + "\n");
+    }
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, largeContent.toString(), "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private void assertContextLines(
+      String changeId, int contextPadding, ImmutableList<Integer> expectedLines) throws Exception {
+    List<CommentInfo> comments =
+        gApi.changes()
+            .id(changeId)
+            .commentsRequest()
+            .withContext(true)
+            .contextPadding(contextPadding)
+            .getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(
+            comments.get(0).contextLines.stream()
+                .map(c -> c.lineNumber)
+                .collect(Collectors.toList()))
+        .containsExactlyElementsIn(expectedLines);
+  }
+
   private Comment.Range createCommentRange(int startLine, int endLine) {
     Comment.Range range = new Comment.Range();
     range.startLine = startLine;
diff --git a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
index 0e4fbc8..12045b1 100644
--- a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class AccountIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedAccountId).isEqualTo(accountId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Account_Id proto = Entities.Account_Id.newBuilder().setId(24).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Account_Id> parser = accountIdProtoConverter.getParser();
-    Entities.Account_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
index 0a73db8..7073fa7 100644
--- a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Branch_NameKey proto =
-        Entities.Branch_NameKey.newBuilder()
-            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
-            .setBranch("branch 36")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Branch_NameKey> parser = branchNameKeyProtoConverter.getParser();
-    Entities.Branch_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
index 12f3f33..fe71c42 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeId).isEqualTo(changeId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Id proto = Entities.Change_Id.newBuilder().setId(94).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Id> parser = changeIdProtoConverter.getParser();
-    Entities.Change_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
index e9080b3..745c90c 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeKeyProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeKey).isEqualTo(changeKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Key proto = Entities.Change_Key.newBuilder().setId("change 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Key> parser = changeKeyProtoConverter.getParser();
-    Entities.Change_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
index 72ce896..98329d2 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedMessageKey).isEqualTo(messageKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage_Key proto =
-        Entities.ChangeMessage_Key.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(704))
-            .setUuid("aabbcc")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
-    Entities.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index 933ffb4..b185558 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -174,23 +173,6 @@
     assertThat(convertedChangeMessage).isEqualTo(changeMessage);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage proto =
-        Entities.ChangeMessage.newBuilder()
-            .setKey(
-                Entities.ChangeMessage_Key.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
-                    .setUuid("change-message-21"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage> parser = changeMessageProtoConverter.getParser();
-    Entities.ChangeMessage parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bc669cc..ae8e06d 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -277,40 +276,6 @@
     assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change proto =
-        Entities.Change.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
-            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
-            .setRowVersion(0)
-            .setCreatedOn(987654L)
-            .setLastUpdatedOn(1234567L)
-            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
-            .setDest(
-                Entities.Branch_NameKey.newBuilder()
-                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranch("branch 74"))
-            .setStatus(Change.STATUS_MERGED)
-            .setCurrentPatchSetId(23)
-            .setSubject("subject XYZ")
-            .setTopic("my topic")
-            .setOriginalSubject("original subject ABC")
-            .setSubmissionId("submission ID 234")
-            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
-            .setIsPrivate(true)
-            .setWorkInProgress(true)
-            .setReviewStarted(true)
-            .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change> parser = changeProtoConverter.getParser();
-    Entities.Change parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
index 88b9fb6..6237ac0 100644
--- a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class LabelIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedLabelId).isEqualTo(labelId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.LabelId proto = Entities.LabelId.newBuilder().setId("label-23").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.LabelId> parser = labelIdProtoConverter.getParser();
-    Entities.LabelId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
index 8408b69..447c47f 100644
--- a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -48,18 +47,6 @@
     assertThat(convertedObjectId).isEqualTo(objectId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ObjectId proto =
-        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
-    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
index 11aac0d..be55561 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -65,25 +64,6 @@
     assertThat(convertedKey).isEqualTo(key);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval_Key proto =
-        Entities.PatchSetApproval_Key.newBuilder()
-            .setPatchSetId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setId(14))
-            .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval_Key> parser = protoConverter.getParser();
-    Entities.PatchSetApproval_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index bca5eea..bf39ff8 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
@@ -165,29 +164,6 @@
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval proto =
-        Entities.PatchSetApproval.newBuilder()
-            .setKey(
-                Entities.PatchSetApproval_Key.newBuilder()
-                    .setPatchSetId(
-                        Entities.PatchSet_Id.newBuilder()
-                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setId(14))
-                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
-            .setValue(456)
-            .setGranted(987654L)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval> parser = protoConverter.getParser();
-    Entities.PatchSetApproval parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
index 530b431..c858582 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedPatchSetId).isEqualTo(patchSetId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet_Id proto =
-        Entities.PatchSet_Id.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setId(73)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet_Id> parser = patchSetIdProtoConverter.getParser();
-    Entities.PatchSet_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 2519e75..efeb24f 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -148,23 +147,6 @@
                 .build());
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet proto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setId(73))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
-    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
index 2f693e6..2fa89a5 100644
--- a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ProjectNameKeyProtoConverterTest {
@@ -50,18 +49,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Project_NameKey proto =
-        Entities.Project_NameKey.newBuilder().setName("project 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Project_NameKey> parser = projectNameKeyProtoConverter.getParser();
-    Entities.Project_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 248c7d1..9a6b82b 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -49,6 +49,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
@@ -82,5 +83,6 @@
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
index 84f290c..643c7b7 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -34,6 +34,7 @@
             .id("commentId")
             .path("pathHash")
             .patchset(1)
+            .contextPadding(3)
             .build();
     byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
     assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index 29f520b..4902830 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -93,12 +93,14 @@
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(alternateBasePath.toRealPath().toString());
 
     repo = repoManager.openRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(alternateBasePath.toRealPath().toString());
 
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(alternateBasePath.toString());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index a1a1ca3..67181b7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
-import static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -40,6 +39,8 @@
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -332,7 +333,8 @@
             .uploader(Account.id(2000))
             .createdOn(cols.createdOn())
             .build();
-    ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
+    ByteString ps1Bytes = Protos.toByteString(ps1Proto);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
     PatchSet ps2 =
@@ -342,7 +344,8 @@
             .uploader(Account.id(3000))
             .createdOn(cols.lastUpdatedOn())
             .build();
-    ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
+    ByteString ps2Bytes = Protos.toByteString(ps2Proto);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
@@ -352,8 +355,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addPatchSet(ps2Bytes)
-            .addPatchSet(ps1Bytes)
+            .addPatchSet(ps2Proto)
+            .addPatchSet(ps1Proto)
             .build());
   }
 
@@ -367,8 +370,8 @@
             .value(1)
             .granted(new Timestamp(1212L))
             .build();
-    ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
-    assertThat(a1Bytes.size()).isEqualTo(43);
+    Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
+    ByteString a1Bytes = Protos.toByteString(psa1);
 
     PatchSetApproval a2 =
         PatchSetApproval.builder()
@@ -378,7 +381,8 @@
             .value(-1)
             .granted(new Timestamp(3434L))
             .build();
-    ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
@@ -390,8 +394,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addApproval(a2Bytes)
-            .addApproval(a1Bytes)
+            .addApproval(psa2)
+            .addApproval(psa1)
             .build());
   }
 
@@ -722,7 +726,8 @@
             Account.id(1000),
             new Timestamp(1212L),
             PatchSet.id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
+    ByteString m1Bytes = Protos.toByteString(m1Proto);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
@@ -731,7 +736,8 @@
             Account.id(2000),
             new Timestamp(3434L),
             PatchSet.id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
+    ByteString m2Bytes = Protos.toByteString(m2Proto);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
 
@@ -741,8 +747,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addChangeMessage(m2Bytes)
-            .addChangeMessage(m1Bytes)
+            .addChangeMessage(m2Proto)
+            .addChangeMessage(m1Proto)
             .build());
   }
 
@@ -1007,6 +1013,60 @@
                 .build());
   }
 
+  /* Transitional test. Remove once follow-up change is live without accidents. */
+  @Test
+  public void binaryCompatibility() throws Exception {
+    ChangeNotesState.Builder builder = newBuilder();
+    PatchSet ps1 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 1))
+            .commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
+            .uploader(Account.id(2000))
+            .createdOn(cols.createdOn())
+            .build();
+    PatchSetApproval a1 =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    ps1.id(), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
+            .value(1)
+            .granted(new Timestamp(1212L))
+            .build();
+
+    ChangeMessage m1 =
+        new ChangeMessage(
+            ChangeMessage.key(ID, "uuid1"),
+            Account.id(1000),
+            new Timestamp(1212L),
+            PatchSet.id(ID, 1));
+    ChangeNotesState state =
+        builder
+            .approvals(ImmutableMap.of(PatchSet.id(ID, 1), a1).entrySet())
+            .patchSets(ImmutableMap.of(ps1.id(), ps1).entrySet())
+            .changeMessages(ImmutableList.of(m1))
+            .build();
+
+    byte got[] = ChangeNotesState.Serializer.INSTANCE.serialize(state);
+    byte want[] =
+        new byte[] {
+          10, 20, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86,
+          120, 16, 123, 26, 89, 10, 41, 73, 97, 98, 99, 100, 97, 98, 99, 100, 97, 98, 99, 100, 97,
+          98, 99, 100, 97, 98, 99, 100, 97, 98, 99, 100, 97, 98, 99, 100, 97, 98, 99, 100, 97, 98,
+          99, 100, 97, 98, 99, 100, 16, -64, -60, 7, 24, -57, -88, 14, 32, -24, 7, 42, 17, 114, 101,
+          102, 115, 47, 104, 101, 97, 100, 115, 47, 109, 97, 115, 116, 101, 114, 66, 11, 84, 101,
+          115, 116, 32, 99, 104, 97, 110, 103, 101, -88, 1, 1, 50, 66, 10, 6, 10, 2, 8, 123, 16, 1,
+          18, 42, 10, 40, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97,
+          97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97, 97,
+          26, 3, 8, -48, 15, 33, 64, -30, 1, 0, 0, 0, 0, 0, 58, 43, 10, 28, 10, 6, 10, 2, 8, 123,
+          16, 1, 18, 3, 8, -47, 15, 26, 13, 10, 11, 67, 111, 100, 101, 45, 82, 101, 118, 105, 101,
+          119, 16, 1, 25, -68, 4, 0, 0, 0, 0, 0, 0, 64, 0, 122, 35, 10, 11, 10, 2, 8, 123, 18, 5,
+          117, 117, 105, 100, 49, 18, 3, 8, -24, 7, 25, -68, 4, 0, 0, 0, 0, 0, 0, 42, 6, 10, 2, 8,
+          123, 16, 1
+        };
+
+    assertThat(got).isEqualTo(want);
+  }
+
   @Test
   public void commentFields() throws Exception {
     assertThatSerializedClass(Comment.Key.class)
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 5cefe74..b3e0c56 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -25,6 +25,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -33,7 +34,6 @@
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -55,10 +55,10 @@
         .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
     // Make sure no comment is linked to the auto-gen message
-    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(
-            /* expected: String, actual: ChangeMessage */ getChangeMessage(
-                changeMessages, "cmAutoGenByGerrit"));
+    Set<String> changeMessageIds =
+        comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet());
+    assertThat(changeMessageIds)
+        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit").getKey().uuid());
   }
 
   @Test
diff --git a/lib/LICENSE-PublicDomain b/lib/LICENSE-PublicDomain
new file mode 100644
index 0000000..8a71ce0
--- /dev/null
+++ b/lib/LICENSE-PublicDomain
@@ -0,0 +1 @@
+This software has been placed in the public domain by its author(s).
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 14179d6..f73984b 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -1,9 +1,4 @@
-load("@rules_java//java:defs.bzl", "java_import", "java_library")
-
-java_import(
-    name = "guice-library-no-aop",
-    jars = ["@guice-library-no-aop//file"],
-)
+load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
     name = "guice",
@@ -19,7 +14,8 @@
     name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = [":guice-library-no-aop"],
+    exports = ["@guice-library//jar"],
+    runtime_deps = ["aopalliance"],
 )
 
 java_library(
@@ -39,6 +35,12 @@
 )
 
 java_library(
+    name = "aopalliance",
+    data = ["//lib:LICENSE-PublicDomain"],
+    exports = ["@aopalliance//jar"],
+)
+
+java_library(
     name = "javax_inject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index f94486c..f596164 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -23,7 +23,7 @@
 flogger-system-backend
 guava
 guice-assistedinject
-guice-library-no-aop
+guice-library
 guice-servlet
 httpasyncclient
 httpcore-nio
diff --git a/package.json b/package.json
index ab23092..fc4161b 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,9 @@
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/rollup": "^3.1.0",
-    "@bazel/terser": "^3.1.0",
-    "@bazel/typescript": "^3.1.0"
+    "@bazel/rollup": "^3.2.0",
+    "@bazel/terser": "^3.2.0",
+    "@bazel/typescript": "^3.2.0"
   },
   "devDependencies": {
     "@typescript-eslint/eslint-plugin": "^4.11.0",
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index a0c53c6..3548ec8 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit a0c53c6c5ad1ba8f8967ed6d2bcb18995f734cad
+Subproject commit 3548ec83c0c271a8768a6b03b0c28711521ed6cf
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 764d5e8..a3da3cf 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -55,6 +55,7 @@
     }],
     // https://eslint.org/docs/rules/eol-last
     'eol-last': 'off',
+    'guard-for-in': 'error',
     // https://eslint.org/docs/rules/indent
     'indent': ['error', 2, {
       MemberExpression: 2,
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index 91c2b7b..5820139 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -32,7 +32,7 @@
  * however a range with end_line set to 5 and end_character equal to 0 will not
  * include any characters on line 5.
  */
-export interface CommentRange {
+export declare interface CommentRange {
   /** The start line number of the range. (1-based) */
   start_line: number;
 
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 496653f..5d7125c 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -219,13 +219,16 @@
   end_line: number;
 }
 
-export interface CoverageRange {
+export declare interface CoverageRange {
   type: CoverageType;
   side: Side;
   code_range: LineRange;
 }
 
-export declare type LineNumber = number | 'FILE';
+/** LOST LineNumber is for ported comments without a range, they have their own
+ *  line number and are added on top of the FILE row in gr-diff
+ */
+export declare type LineNumber = number | 'FILE' | 'LOST';
 
 /** The detail of the 'create-comment' event dispatched by gr-diff. */
 export declare interface CreateCommentEventDetail {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 2c66a3e..e77a5e8 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -178,7 +178,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _visibleToAll(item: GroupInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index ed196e4..2df1ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -155,7 +155,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 0b1e27e..2124949 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -34,7 +34,6 @@
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {appContext} from '../../../services/app-context';
@@ -174,19 +173,12 @@
       .then(response => {
         if (!response) return [];
         const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 5902717..b68f720 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -55,6 +55,10 @@
     this.hasNewGroupName = !!name;
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   handleCreateGroup() {
     const name = this._name as GroupName;
     return this.restApiService.createGroup({name}).then(groupRegistered => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index d3ce98a..c9fd241 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -28,7 +28,6 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, observe, property} from '@polymer/decorators';
 import {ProjectInput, RepoName} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
 
@@ -83,6 +82,10 @@
     return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   @observe('_repoConfig.name')
   _updateRepoName(name: string) {
     this.hasNewRepoName = !!name;
@@ -111,14 +114,8 @@
   _getRepoSuggestions(input: string) {
     return this.restApiService.getSuggestedProjects(input).then(response => {
       const repos = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        repos.push({
-          name: key,
-          value: response[key].id,
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        repos.push({name, value: project.id});
       }
       return repos;
     });
@@ -127,14 +124,8 @@
   _getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 888647f..f5602a3 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -39,9 +39,11 @@
   GroupInfo,
   GroupName,
 } from '../../../types/common';
-import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   fireAlert,
   firePageError,
@@ -339,23 +341,18 @@
     return this.restApiService
       .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
       .then(accounts => {
+        if (!accounts) return [];
         const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) {
-          return [];
-        }
-        for (const key in accounts) {
-          if (!hasOwnProperty(accounts, key)) {
-            continue;
-          }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+        for (const account of accounts) {
+          let nameAndEmail;
+          if (account.email !== undefined) {
+            nameAndEmail = `${account.name} <${account.email}>`;
           } else {
-            nameAndEmail = accounts[key].name;
+            nameAndEmail = account.name;
           }
           accountSuggestions.push({
             name: nameAndEmail,
-            value: accounts[key]._account_id?.toString(),
+            value: account._account_id?.toString(),
           });
         }
         return accountSuggestions;
@@ -364,15 +361,9 @@
 
   _getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      const groups: AutocompleteSuggestion[] = [];
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index 654d24a..672ac07 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -100,7 +100,7 @@
           },
         ]);
       } else {
-        return Promise.resolve({});
+        return Promise.resolve([]);
       }
     });
     stubRestApi('getSuggestedGroups').callsFake(input => {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index cee803c..058f86b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -33,7 +33,6 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {ErrorCallback} from '../../../services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   fireEvent,
   firePageError,
@@ -301,14 +300,8 @@
   _getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 65a3b00..5702bfb 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -33,7 +33,6 @@
   PermissionArray,
 } from '../../../utils/access-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -333,14 +332,8 @@
       .getSuggestedGroups(this._groupFilter || '', MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const groups: GroupSuggestion[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          groups.push({
-            name: key,
-            value: response[key],
-          });
+        for (const [name, value] of Object.entries(response ?? {})) {
+          groups.push({name, value});
         }
         // Does not return groups in which we already have rules for.
         return groups
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 9fb79d3..935d091 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -37,7 +37,6 @@
   UrlEncodedRepoName,
   ProjectAccessGroups,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {
@@ -370,11 +369,9 @@
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
    */
-  _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         if (node.deleted) {
@@ -387,17 +384,15 @@
   }
 
   _recursivelyUpdateAddRemoveObj(
-    obj: PropertyTreeNode,
+    obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
     },
     path: string[] = []
   ) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         const updatedId = node.updatedId;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 6f6f926..c1c8475 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -169,7 +169,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _readOnly(repo: ProjectInfoWithName) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index 6bf73d1..4904bf4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -151,7 +151,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 1c30e96..3543e3b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -392,21 +392,14 @@
     schemesObj?: SchemesInfoMap,
     _selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
+    if (!schemesObj || !repo || !_selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
+    const commandObj = schemesObj[_selectedScheme].clone_commands;
     const commands = [];
-    let commandObj: {[title: string]: string} = {};
-    if (hasOwnProperty(schemesObj, _selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!hasOwnProperty(commandObj, title)) {
-        continue;
-      }
+    for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
         title,
-        command: commandObj[title]
+        command: command
           .replace(/\${project}/gi, encodeURI(repo))
           .replace(
             /\${project-base-name}/gi,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 973ccc8..441d514 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -37,17 +37,16 @@
 } from '../../../types/common';
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {ChangeListViewState} from '../../../types/types';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 
-const LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  COMMIT: /[0-9a-f]{40}/,
-};
+const LOOKUP_QUERY_PATTERNS: RegExp[] = [
+  /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
+  /^\s*[1-9][0-9]*\s*$/g, // CHANGE_NUM
+  /[0-9a-f]{40}/, // COMMIT
+];
 
 const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
@@ -159,12 +158,8 @@
       .then(changes => {
         changes = changes || [];
         if (this._query && changes.length === 1) {
-          let query: keyof typeof LookupQueryPatterns;
-          for (query in LookupQueryPatterns) {
-            if (
-              hasOwnProperty(LookupQueryPatterns, query) &&
-              this._query.match(LookupQueryPatterns[query])
-            ) {
+          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+            if (this._query.match(queryPattern)) {
               // "Back"/"Forward" buttons work correctly only with
               // opt_redirect options
               GerritNav.navigateToChange(
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 9ad0b05f..f36df84 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
@@ -55,6 +55,7 @@
 } from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -478,7 +479,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _toggleChangeStar(e: CustomKeyboardEvent) {
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 7008b48..841ee6e 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
@@ -57,7 +57,6 @@
 import {DashboardViewState} from '../../../types/types';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
 const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
@@ -124,8 +123,6 @@
 
   private restApiService = appContext.restApiService;
 
-  private flagService = appContext.flagsService;
-
   private lastVisibleTimestampMs = 0;
 
   constructor() {
@@ -140,19 +137,17 @@
       e.stopPropagation();
       this._reload(this.params);
     });
-    if (this.flagService.isEnabled(KnownExperimentId.AUTO_RELOAD_DASHBOARD)) {
-      document.addEventListener('visibilitychange', () => {
-        if (document.visibilityState === 'visible') {
-          if (
-            Date.now() - this.lastVisibleTimestampMs >
-            RELOAD_DASHBOARD_INTERVAL_MS
-          )
-            this._reload(this.params);
-        } else {
-          this.lastVisibleTimestampMs = Date.now();
-        }
-      });
-    }
+    document.addEventListener('visibilitychange', () => {
+      if (document.visibilityState === 'visible') {
+        if (
+          Date.now() - this.lastVisibleTimestampMs >
+          RELOAD_DASHBOARD_INTERVAL_MS
+        )
+          this._reload(this.params);
+      } else {
+        this.lastVisibleTimestampMs = Date.now();
+      }
+    });
   }
 
   _loadPreferences() {
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 78c48f8..903afaa 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
@@ -114,6 +114,8 @@
   getApprovalInfo,
   getVotingRange,
 } from '../../../utils/label-util';
+import {CommentThread} from '../../../utils/comment-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -316,6 +318,7 @@
 
 interface ChangeActionDialog extends HTMLElement {
   resetFocus?(): void;
+  init?(): void;
 }
 
 export interface GrChangeActions {
@@ -442,6 +445,9 @@
   @property({type: String})
   _actionLoadingMessage = '';
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @property({
     type: Array,
     computed:
@@ -934,14 +940,14 @@
       return null;
     }
     let result;
-    for (const label in this.change.labels) {
+    for (const [label, labelInfo] of Object.entries(this.change.labels)) {
       if (!(label in this.change.permitted_labels)) {
         continue;
       }
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(this.change.labels[label]);
+      const status = this._getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so it's unclear which to quick
@@ -1594,7 +1600,7 @@
 
   _showActionDialog(dialog: ChangeActionDialog) {
     this._hideAllDialogs();
-
+    if (dialog.init) dialog.init();
     dialog.hidden = false;
     this.$.overlay.open().then(() => {
       if (dialog.resetFocus) {
@@ -1748,7 +1754,7 @@
     return fetchChangeUpdates(change, this.restApiService).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message:
                 'Cannot set label: a newer patch has been ' +
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 2bbfbbf..71500ac 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -216,6 +216,7 @@
       action="[[_revisionSubmitAction]]"
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleSubmitConfirm"
+      comment-threads="[[commentThreads]]"
       hidden=""
     ></gr-confirm-submit-dialog>
     <gr-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 3813a03..88481a4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -14,39 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html, TemplateResult} from 'lit-html';
+import {html} from 'lit-html';
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../../lit/gr-lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
-import {Category, CheckRun, Link} from '../../../api/checks';
-import {allRuns$, RunResult} from '../../../services/checks/checks-model';
-import {fireShowPrimaryTab} from '../../../utils/event-util';
 import {
-  hasCompleted,
+  allRuns$,
+  aPluginHasRegistered,
+} from '../../../services/checks/checks-model';
+import {
+  Category,
+  CheckResult,
+  CheckRun,
+  Link,
+  RunStatus,
+} from '../../../api/checks';
+import {fireShowPrimaryTab} from '../../../utils/event-util';
+import '../../shared/gr-avatar/gr-avatar';
+import {
+  getResultsOf,
+  hasCompletedWithoutResults,
+  hasResultsOf,
+  iconForCategory,
+  iconForStatus,
   isRunning,
-  isRunningOrHasCompleted,
 } from '../../../services/checks/checks-util';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   CommentThread,
   isResolved,
   isUnresolved,
+  getFirstComment,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-
-function filterResults(runs: CheckRun[], category: Category): RunResult[] {
-  return runs.filter(isRunningOrHasCompleted).reduce((results, run) => {
-    return results.concat(
-      (run.results ?? [])
-        .filter(result => result.category === category)
-        .map(result => {
-          return {...run, ...result};
-        })
-    );
-  }, [] as RunResult[]);
-}
+import {AccountInfo} from '../../../types/common';
+import {notUndefined} from '../../../types/types';
+import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {PrimaryTab} from '../../../constants/constants';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -71,8 +77,7 @@
           color: var(--chip-color);
           cursor: pointer;
           display: inline-block;
-          margin-right: var(--spacing-s);
-          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+          padding: var(--spacing-xxs) var(--spacing-s) var(--spacing-xxs)
             var(--spacing-s);
           border-radius: 12px;
           border: 1px solid gray;
@@ -111,12 +116,20 @@
   render() {
     const chipClass = `summaryChip font-small ${this.styleType}`;
     const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
-    return html`
-      <div class="${chipClass}" role="button">
-        ${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
-        <slot></slot>
-      </div>
-    `;
+    return html`<div
+      class="${chipClass}"
+      role="button"
+      @click="${this.handleClick}"
+    >
+      ${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
+      <slot></slot>
+    </div>`;
+  }
+
+  private handleClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS);
   }
 }
 
@@ -126,18 +139,15 @@
   icon = '';
 
   @property()
-  expandMax = 0;
-
-  @property()
-  runs: CheckRun[] = [];
-
-  @property()
-  results: RunResult[] = [];
+  text = '';
 
   static get styles() {
     return [
       sharedStyles,
       css`
+        :host {
+          display: inline-block;
+        }
         .checksChip {
           color: var(--chip-color);
           cursor: pointer;
@@ -149,7 +159,7 @@
           border: 1px solid gray;
           vertical-align: top;
         }
-        .checksChip .checkName {
+        .checksChip .text {
           display: inline-block;
           max-width: 120px;
           white-space: nowrap;
@@ -162,11 +172,8 @@
           height: var(--line-height-small);
           vertical-align: top;
         }
-        div.checksChip iron-icon.launch {
-          color: var(--gray-foreground);
-        }
         .checksChip.error {
-          color: var(--error-color);
+          color: var(--error-foreground);
           border-color: var(--error-foreground);
           background-color: var(--error-background);
         }
@@ -187,12 +194,14 @@
         .checksChip.info-outline iron-icon {
           color: var(--info-foreground);
         }
-        .checksChip.check {
-          border-color: var(--gray-foreground);
-          background-color: var(--gray-background);
+        .checksChip.check-circle-outline {
+          border-color: var(--success-foreground);
+          background-color: var(--success-background);
         }
-        .checksChip.check iron-icon {
-          color: var(--gray-foreground);
+        .checksChip.check-circle-outline iron-icon {
+          color: var(--success-foreground);
+        }
+        .checksChip.timelapse {
         }
         .checksChip.timelapse {
           border-color: var(--gray-foreground);
@@ -206,83 +215,56 @@
   }
 
   render() {
-    const count = this.runs.length || this.results.length;
-    if (count === 0) return;
-    if (count > this.expandMax || !this.results.length) {
-      return this.renderChip(html`${count}`);
-    }
-    return this.results.map(result =>
-      this.renderChip(this.renderNameAndLinks(result))
-    );
-  }
-
-  private renderChip(content: TemplateResult) {
+    if (!this.text) return;
     const chipClass = `checksChip font-small ${this.icon}`;
     const grIcon = `gr-icons:${this.icon}`;
     return html`
       <div class="${chipClass}" role="button" @click="${this.handleClick}">
         <iron-icon icon="${grIcon}"></iron-icon>
-        ${content}
+        <div class="text">${this.text}</div>
+        <slot></slot>
       </div>
     `;
   }
 
-  private renderNameAndLinks(result: RunResult) {
-    return html`
-      <div class="checkName">${result.checkName}</div>
-      ${this.renderResultLinks(result.links ?? [])}
-    `;
-  }
-
-  private renderResultLinks(links: Link[]) {
-    return links
-      .filter(link => link.primary)
-      .slice(0, 2)
-      .map(
-        link => html`
-          <a
-            href="${link.url}"
-            target="_blank"
-            @click="${this.handleClickLink}"
-          >
-            <iron-icon class="launch" icon="gr-icons:launch"></iron-icon>
-          </a>
-        `
-      );
-  }
-
-  private handleClick() {
-    fireShowPrimaryTab(this, 'checks');
-  }
-
-  private handleClickLink(e: Event) {
-    // Prevents handleClick() from reacting to <a> link clicks.
+  private handleClick(e: MouseEvent) {
     e.stopPropagation();
+    e.preventDefault();
+    fireShowPrimaryTab(this, PrimaryTab.CHECKS);
   }
 }
 
+/** What is the maximum number of expanded checks chips? */
+const DETAILS_QUOTA = 3;
+
 @customElement('gr-change-summary')
 export class GrChangeSummary extends GrLitElement {
-  private readonly ciRebootChecksEnabled = appContext.flagsService.isEnabled(
-    KnownExperimentId.CI_REBOOT_CHECKS
-  );
-
   private readonly newChangeSummaryUiEnabled = appContext.flagsService.isEnabled(
     KnownExperimentId.NEW_CHANGE_SUMMARY_UI
   );
 
-  @property({type: Array})
+  @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Object})
+  @property({type: Array})
   commentThreads?: CommentThread[];
 
+  @property({type: Object})
+  selfAccount?: AccountInfo;
+
   @property()
   runs: CheckRun[] = [];
 
+  @property()
+  showChecksSummary = false;
+
+  /** Is reset when rendering beings and decreases while chips are rendered. */
+  private detailsQuota = DETAILS_QUOTA;
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
+    this.subscribe('showChecksSummary', aPluginHasRegistered);
   }
 
   static get styles() {
@@ -299,59 +281,119 @@
         }
         td.key {
           padding-right: var(--spacing-l);
+          padding-bottom: var(--spacing-m);
         }
         td.value {
           padding-right: var(--spacing-l);
+          padding-bottom: var(--spacing-m);
         }
-        .runs {
-          margin-right: var(--spacing-s);
-          margin-left: var(--spacing-m);
+        iron-icon.launch {
+          color: var(--gray-foreground);
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        gr-avatar {
+          height: var(--line-height-small, 16px);
+          width: var(--line-height-small, 16px);
+          vertical-align: top;
+          margin-right: var(--spacing-xs);
         }
       `,
     ];
   }
 
+  renderChecksChipForCategory(category: Category) {
+    const icon = iconForCategory(category);
+    const runs = this.runs.filter(run => hasResultsOf(run, category));
+    const count = (run: CheckRun) => getResultsOf(run, category);
+    return this.renderChecksChip(icon, runs, count);
+  }
+
+  renderChecksChipForStatus(
+    status: RunStatus,
+    filter: (run: CheckRun) => boolean
+  ) {
+    const icon = iconForStatus(status);
+    const runs = this.runs.filter(filter);
+    return this.renderChecksChip(icon, runs, () => []);
+  }
+
+  renderChecksChip(
+    icon: string,
+    runs: CheckRun[],
+    resultFilter: (run: CheckRun) => CheckResult[]
+  ) {
+    if (runs.length === 0) {
+      return html``;
+    }
+    if (runs.length <= this.detailsQuota) {
+      this.detailsQuota -= runs.length;
+      return runs.map(run => {
+        const allLinks = resultFilter(run)
+          .reduce((links, result) => {
+            return links.concat(result.links ?? []);
+          }, [] as Link[])
+          .filter(link => link.primary);
+        const links = allLinks.length === 1 ? allLinks : [];
+        const text = `${run.checkName}`;
+        return html`<gr-checks-chip
+          class="${icon}"
+          .icon="${icon}"
+          .text="${text}"
+          >${links.map(
+            link => html`
+              <a href="${link.url}" target="_blank" @click="${this.onClick}"
+                ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
+              ></a>
+            `
+          )}
+        </gr-checks-chip>`;
+      });
+    }
+    // runs.length > this.detailsQuota
+    this.detailsQuota = 0;
+    const sum = runs.reduce(
+      (sum, run) => sum + (resultFilter(run).length || 1),
+      0
+    );
+    if (sum === 0) return;
+    return html`<gr-checks-chip
+      class="${icon}"
+      .icon="${icon}"
+      .text="${sum}"
+    ></gr-checks-chip>`;
+  }
+
+  private onClick(e: MouseEvent) {
+    // Prevents handleClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
+
   render() {
-    const runs: CheckRun[] = this.runs;
-    const errors = filterResults(runs, Category.ERROR);
-    const warnings = filterResults(runs, Category.WARNING);
-    const infos = filterResults(runs, Category.INFO);
-    const numResolvedComments =
+    this.detailsQuota = DETAILS_QUOTA;
+    const countResolvedComments =
       this.commentThreads?.filter(isResolved).length ?? 0;
-    const numUnResolvedComments =
-      this.commentThreads?.filter(isUnresolved).length ?? 0;
+    const unresolvedThreads = this.commentThreads?.filter(isUnresolved) ?? [];
+    const countUnresolvedComments = unresolvedThreads.length;
+    const unresolvedAuthors = this.getAccounts(unresolvedThreads);
     const draftCount = this.changeComments?.computeDraftCount() ?? 0;
     return html`
       <div>
         <table>
-          <tr ?hidden=${!this.ciRebootChecksEnabled}>
+          <tr ?hidden=${!this.showChecksSummary}>
             <td class="key">Checks</td>
             <td class="value">
-              <gr-checks-chip
-                icon="error"
-                .results="${errors}"
-                expandMax="2"
-              ></gr-checks-chip>
-              <gr-checks-chip
-                icon="warning"
-                .results="${warnings}"
-                expandMax="${2 - errors.length}"
-              ></gr-checks-chip>
-              <gr-checks-chip
-                icon="info-outline"
-                .results="${infos}"
-              ></gr-checks-chip>
-              <span ?hidden=${!runs.some(isRunningOrHasCompleted)} class="runs"
-                >Runs</span
-              >
-              <gr-checks-chip
-                icon="check"
-                .runs="${runs.filter(hasCompleted)}"
-              ></gr-checks-chip>
-              <gr-checks-chip
-                icon="timelapse"
-                .runs="${runs.filter(isRunning)}"
-              ></gr-checks-chip>
+              ${this.renderChecksChipForCategory(
+                Category.ERROR
+              )}${this.renderChecksChipForCategory(
+                Category.WARNING
+              )}${this.renderChecksChipForCategory(
+                Category.INFO
+              )}${this.renderChecksChipForStatus(
+                RunStatus.COMPLETED,
+                hasCompletedWithoutResults
+              )}${this.renderChecksChipForStatus(RunStatus.RUNNING, isRunning)}
             </td>
           </tr>
           <tr ?hidden=${!this.newChangeSummaryUiEnabled}>
@@ -359,9 +401,9 @@
             <td class="value">
               <gr-summary-chip
                 styleType=${SummaryChipStyles.INFO}
-                ?hidden=${!!numResolvedComments ||
+                ?hidden=${!!countResolvedComments ||
                 !!draftCount ||
-                !!numUnResolvedComments}
+                !!countUnresolvedComments}
               >
                 No Comments</gr-summary-chip
               >
@@ -375,14 +417,23 @@
               <gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 icon="message"
-                ?hidden=${!numUnResolvedComments}
-                >${numUnResolvedComments} unresolved</gr-summary-chip
+                ?hidden=${!countUnresolvedComments}
+              >
+                ${unresolvedAuthors.map(
+                  account =>
+                    html`<gr-avatar
+                      .account="${account}"
+                      image-size="32"
+                      aria-label="Account avatar"
+                    ></gr-avatar>`
+                )}
+                ${countUnresolvedComments} unresolved</gr-summary-chip
               >
               <gr-summary-chip
                 styleType=${SummaryChipStyles.CHECK}
                 icon="markChatRead"
-                ?hidden=${!numResolvedComments}
-                >${numResolvedComments} resolved</gr-summary-chip
+                ?hidden=${!countResolvedComments}
+                >${countResolvedComments} resolved</gr-summary-chip
               >
             </td>
           </tr>
@@ -394,6 +445,16 @@
       </div>
     `;
   }
+
+  getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
+    const uniqueAuthors = commentThreads
+      .map(getFirstComment)
+      .map(comment => comment?.author ?? this.selfAccount)
+      .filter(notUndefined)
+      .filter(account => !!account?.avatars?.[0]?.url)
+      .filter(uniqueDefinedAvatar);
+    return uniqueAuthors.slice(0, 3);
+  }
 }
 
 declare global {
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 6d654d8..7fd7126 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
@@ -55,7 +55,10 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {getComputedStyleValue} from '../../../utils/dom-util';
+import {
+  getComputedStyleValue,
+  windowLocationReload,
+} from '../../../utils/dom-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -146,6 +149,7 @@
   CustomKeyboardEvent,
   EditableContentSaveEvent,
   OpenFixPreviewEvent,
+  ShowAlertEventDetail,
   SwitchTabEvent,
   ThreadListModifiedEvent,
 } from '../../../types/events';
@@ -161,6 +165,10 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
+import {takeUntil} from 'rxjs/operators';
+import {aPluginHasRegistered} from '../../../services/checks/checks-model';
+import {Subject} from 'rxjs';
+import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -367,13 +375,6 @@
   })
   _hideEditCommitMessage?: boolean;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeHideShowAllContainer(_hideEditCommitMessage, _commitCollapsible)',
-  })
-  _hideShowAllContainer = false;
-
   @property({type: String})
   _diffAgainst?: string;
 
@@ -544,7 +545,8 @@
 
   _throttledToggleChangeStar?: EventListener;
 
-  _isChecksEnabled = false;
+  @property({type: Boolean})
+  _showChecksTab = false;
 
   @property({type: Boolean})
   _isNewChangeSummaryUiEnabled = false;
@@ -576,12 +578,14 @@
     };
   }
 
+  disconnected$ = new Subject();
+
   /** @override */
   ready() {
     super.ready();
-    this._isChecksEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.CI_REBOOT_CHECKS
-    );
+    aPluginHasRegistered.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+      this._showChecksTab = b;
+    });
     this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
       KnownExperimentId.NEW_CHANGE_SUMMARY_UI
     );
@@ -596,6 +600,12 @@
   }
 
   /** @override */
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+
+  /** @override */
   created() {
     super.created();
 
@@ -889,7 +899,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _handleCommitMessageCancel() {
@@ -918,13 +928,6 @@
     return changeStatuses(change, options);
   }
 
-  _computeHideShowAllContainer(
-    _hideEditCommitMessage?: boolean,
-    _commitCollapsible?: boolean
-  ) {
-    return !_commitCollapsible && _hideEditCommitMessage;
-  }
-
   _computeHideEditCommitMessage(
     loggedIn: boolean,
     editing: boolean,
@@ -2074,21 +2077,15 @@
   }
 
   _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
-    if (change.current_revision) {
-      return change.current_revision;
-    }
+    if (change.current_revision) return change.current_revision;
     // current_revision may not be present in the case where the latest rev is
     // a draft and the user doesn’t have permission to view that rev.
     let latestRev = null;
     let latestPatchNum = -1 as PatchSetNum;
-    for (const rev in change.revisions) {
-      if (!hasOwnProperty(change.revisions, rev)) {
-        continue;
-      }
-
-      if (change.revisions[rev]._number > latestPatchNum) {
+    for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
+      if (revInfo._number > latestPatchNum) {
         latestRev = rev;
-        latestPatchNum = change.revisions[rev]._number;
+        latestPatchNum = revInfo._number;
       }
     }
     return latestRev;
@@ -2284,6 +2281,9 @@
       this._editingCommitMessage = false;
       const relatedChangesLoaded = coreDataPromise.then(() => {
         this.getRelatedChangesList()?.reload();
+        if (this._isNewChangeSummaryUiEnabled) {
+          this.getRelatedChangesListExperimental()?.reload();
+        }
       });
       allDataPromises.push(relatedChangesLoaded);
     }
@@ -2363,6 +2363,9 @@
   }
 
   _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    if (this._isNewChangeSummaryUiEnabled) {
+      return false;
+    }
     return collapsible && collapsed;
   }
 
@@ -2371,9 +2374,6 @@
   }
 
   _computeCollapseText(collapsed: boolean) {
-    if (this._isNewChangeSummaryUiEnabled) {
-      return collapsed ? 'Show all' : 'Show less';
-    }
     // Symbols are up and down triangles.
     return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
   }
@@ -2574,11 +2574,12 @@
 
         this._cancelUpdateCheckTimer();
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message: toastMessage,
               // Persist this alert.
               dismissOnNavigation: true,
+              showDismiss: true,
               action: 'Reload',
               callback: () => {
                 this._reload(
@@ -2807,6 +2808,12 @@
       '#relatedChanges'
     );
   }
+
+  getRelatedChangesListExperimental() {
+    return this.shadowRoot!.querySelector<GrRelatedChangesListExperimental>(
+      '#relatedChangesExperimental'
+    );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 36e73b2..3c37ec7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -109,25 +109,6 @@
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
-    .show-all-container {
-      background-color: var(--view-background-color);
-      display: flex;
-      justify-content: flex-end;
-      margin-bottom: 8px;
-      border-top-width: 1px;
-      border-top-style: solid;
-      border-radius: 0 0 4px 4px;
-      border-color: var(--border-color);
-      box-shadow: var(--elevation-level-1);
-    }
-    .show-all-container .show-all-button {
-      margin-right: auto;
-    }
-    .show-all-container iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
     .commitMessage gr-linked-text {
       word-break: break-word;
     }
@@ -139,9 +120,6 @@
     .new-change-summary-true #commitMessageEditor {
       --collapsed-max-height: 300px;
     }
-    .new-change-summary-true gr-linked-text {
-      min-height: 160px;
-    }
     .editCommitMessage {
       margin-top: var(--spacing-l);
 
@@ -449,6 +427,7 @@
             on-edit-tap="_handleEditTap"
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
+            comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -492,9 +471,11 @@
               >
                 <gr-editable-content
                   id="commitMessageEditor"
-                  editing="[[_editingCommitMessage]]"
+                  editing="{{_editingCommitMessage}}"
                   content="{{_latestCommitMessage}}"
                   storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
+                  hide-edit-commit-message="[[_hideEditCommitMessage]]"
+                  commit-collapsible="[[_commitCollapsible]]"
                   remove-zero-width-space=""
                   collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]"
                 >
@@ -505,37 +486,6 @@
                     remove-zero-width-space=""
                   ></gr-linked-text>
                 </gr-editable-content>
-                <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
-                  <div
-                    class="show-all-container"
-                    hidden$="[[_hideShowAllContainer]]"
-                  >
-                    <gr-button
-                      link=""
-                      class="show-all-button"
-                      on-click="_toggleCommitCollapsed"
-                      hidden$="[[!_commitCollapsible]]"
-                      ><iron-icon
-                        icon="gr-icons:expand-more"
-                        hidden$="[[!_commitCollapsed]]"
-                      ></iron-icon
-                      ><iron-icon
-                        icon="gr-icons:expand-less"
-                        hidden$="[[_commitCollapsed]]"
-                      ></iron-icon>
-                      [[_computeCollapseText(_commitCollapsed)]]
-                    </gr-button>
-                    <gr-button
-                      link=""
-                      class="edit-commit-message"
-                      title="Edit commit message"
-                      on-click="_handleEditCommitMessage"
-                      hidden$="[[_hideEditCommitMessage]]"
-                      ><iron-icon icon="gr-icons:edit"></iron-icon>
-                      Edit</gr-button
-                    >
-                  </div>
-                </template>
                 <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
                   <gr-button
                     link=""
@@ -580,6 +530,7 @@
                 class$="new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
                 change-comments="[[_changeComments]]"
                 comment-threads="[[_commentThreads]]"
+                self-account="[[_account]]"
               >
               </gr-change-summary>
               <gr-endpoint-decorator name="commit-container">
@@ -592,11 +543,14 @@
                 </gr-endpoint-param>
               </gr-endpoint-decorator>
             </div>
-            <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
-              <gr-related-changes-list-experimental></gr-related-changes-list-experimental>
-            </template>
-            <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
-              <div class="relatedChanges">
+            <div class="relatedChanges">
+              <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+                <gr-related-changes-list-experimental
+                  change="[[_change]]"
+                  id="relatedChangesExperimental"
+                ></gr-related-changes-list-experimental>
+              </template>
+              <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
                 <gr-related-changes-list
                   id="relatedChanges"
                   class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
@@ -618,8 +572,8 @@
                     [[_computeCollapseText(_relatedChangesCollapsed)]]
                   </gr-button>
                 </div>
-              </div>
-            </template>
+              </template>
+            </div>
           </div>
         </div>
       </div>
@@ -639,7 +593,7 @@
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
-      <template is="dom-if" if="[[_isChecksEnabled]]">
+      <template is="dom-if" if="[[_showChecksTab]]">
         <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
           >Checks</paper-tab
         >
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 2855716..10cffba 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -37,7 +37,6 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 
 const SUGGESTIONS_LIMIT = 15;
@@ -399,21 +398,16 @@
     return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then((response: BranchInfo[] | undefined) => {
-        const branches = [];
         if (!response) return [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
+        const branches = [];
+        for (const branchInfo of response) {
+          let branch;
+          if (branchInfo.ref.startsWith('refs/heads/')) {
+            branch = branchInfo.ref.substring('refs/heads/'.length);
           } else {
-            branch = response[key].ref;
+            branch = branchInfo.ref;
           }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name: branch});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index e4ed533..536a4ab 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -39,7 +39,7 @@
           },
         ]);
       } else {
-        return Promise.resolve({});
+        return Promise.resolve([]);
       }
     });
     element = basicFixture.instantiate();
@@ -77,11 +77,9 @@
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getProjectBranchesSuggestions empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions('asdf');
+    assert.isEmpty(branches);
   });
 
   suite('cherry pick topic', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 9e7bdb4..9dcd849 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -22,7 +22,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
 import {customElement, property, observe} from '@polymer/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {NumericChangeId, BranchName} from '../../../types/common';
 import {
   GrAutocomplete,
@@ -109,13 +108,10 @@
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
+        for (const change of response) {
           changes.push({
-            name: `${response[key]._number}: ${response[key].subject}`,
-            value: response[key]._number,
+            name: `${change._number}: ${change.subject}`,
+            value: change._number,
           });
         }
         this._recentChanges = changes;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 6e2e595..df0678ee 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -20,6 +20,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../../styles/shared-styles';
+import '../gr-thread-list/gr-thread-list';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -28,6 +29,7 @@
 import {ChangeInfo, ActionInfo} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
+import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 
 export interface GrConfirmSubmitDialog {
   $: {
@@ -60,6 +62,16 @@
   @property({type: Object})
   action?: ActionInfo;
 
+  @property({type: Array})
+  commentThreads?: CommentThread[] = [];
+
+  @property({type: Boolean})
+  _initialised = false;
+
+  init() {
+    this._initialised = true;
+  }
+
   resetFocus() {
     this.$.dialog.resetFocus();
   }
@@ -72,6 +84,11 @@
     );
   }
 
+  _computeUnresolvedThreads(commentThreads?: CommentThread[]) {
+    if (!commentThreads) return [];
+    return commentThreads.filter(thread => isUnresolved(thread));
+  }
+
   _computeUnresolvedCommentsWarning(change: ChangeInfo) {
     const unresolvedCount = change.unresolved_comment_count;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 6c7b1c2..fae920d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -43,42 +43,59 @@
     on-cancel="_handleCancelTap"
     on-confirm="_handleConfirmTap"
   >
-    <div class="header" slot="header">
-      [[action.label]]
-    </div>
-    <div class="main" slot="main">
-      <gr-endpoint-decorator name="confirm-submit-change">
-        <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
-        <template is="dom-if" if="[[change.is_private]]">
-          <p>
+    <template is="dom-if" if="[[_initialised]]">
+      <div class="header" slot="header">
+        [[action.label]]
+      </div>
+      <div class="main" slot="main">
+        <gr-endpoint-decorator name="confirm-submit-change">
+          <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
+          <template is="dom-if" if="[[change.is_private]]">
+            <p>
+              <iron-icon
+                icon="gr-icons:error"
+                class="warningBeforeSubmit"
+              ></iron-icon>
+              <strong>Heads Up!</strong>
+              Submitting this private change will also make it public.
+            </p>
+          </template>
+          <template is="dom-if" if="[[change.unresolved_comment_count]]">
+            <p>
+              <iron-icon
+                icon="gr-icons:error"
+                class="warningBeforeSubmit"
+              ></iron-icon>
+              [[_computeUnresolvedCommentsWarning(change)]]
+            </p>
+            <gr-thread-list
+              id="commentList"
+              threads="[[_computeUnresolvedThreads(commentThreads)]]"
+              change="[[change]]"
+              change-num="[[change._number]]"
+              logged-in="true"
+              hide-toggle-buttons
+            >
+            </gr-thread-list>
+          </template>
+          <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
             <iron-icon
               icon="gr-icons:error"
               class="warningBeforeSubmit"
             ></iron-icon>
-            <strong>Heads Up!</strong>
-            Submitting this private change will also make it public.
-          </p>
-        </template>
-        <template is="dom-if" if="[[change.unresolved_comment_count]]">
-          <p>
-            <iron-icon
-              icon="gr-icons:error"
-              class="warningBeforeSubmit"
-            ></iron-icon>
-            [[_computeUnresolvedCommentsWarning(change)]]
-          </p>
-        </template>
-        <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
-          <iron-icon
-            icon="gr-icons:error"
-            class="warningBeforeSubmit"
-          ></iron-icon>
-          Your unpublished edit will not be submitted. Did you forget to click
-          <b>PUBLISH</b>?
-        </template>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
+            Your unpublished edit will not be submitted. Did you forget to click
+            <b>PUBLISH</b>?
+          </template>
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="action"
+            value="[[action]]"
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </template>
   </gr-dialog>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
index e16ffdb..e175fda 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
@@ -25,6 +25,7 @@
 
   setup(() => {
     element = basicFixture.instantiate();
+    element._initialised = true;
   });
 
   test('display', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 5949513..ab1e4e6 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -20,7 +20,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-dialog_html';
-import {changeBaseURL} from '../../../utils/change-util';
+import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
@@ -122,14 +122,8 @@
       }
     }
     const commands = [];
-    for (const title in commandObj) {
-      if (!commandObj || !hasOwnProperty(commandObj, title)) {
-        continue;
-      }
-      commands.push({
-        title,
-        command: commandObj[title],
-      });
+    for (const [title, command] of Object.entries(commandObj ?? {})) {
+      commands.push({title, command});
     }
     return commands;
   }
@@ -168,13 +162,9 @@
       return '';
     }
 
-    let shortRev = '';
-    for (const rev in change.revisions) {
-      if (change.revisions[rev]._number === patchNum) {
-        shortRev = rev.substr(0, 7);
-        break;
-      }
-    }
+    const rev = getRevisionKey(change, patchNum) ?? '';
+    const shortRev = rev.substr(0, 7);
+
     return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 1e72ae9..af72b67 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -47,6 +47,9 @@
     .patchInfoOldPatchSet .container.latestPatchContainer {
       display: initial;
     }
+    .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
+      display: none;
+    }
     .latestPatchContainer a {
       text-decoration: none;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index d41a662..efa4c7d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -67,7 +67,6 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
@@ -1241,13 +1240,9 @@
     const files: FileNameToReviewedFileInfoMap = {...filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
     const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!hasOwnProperty(files, filePath)) {
-        continue;
-      }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
+    for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
+      reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
     }
-
     this._files = this._normalizeChangeFilesResponse(files);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 77855df..2d38dcc 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -306,11 +306,8 @@
         '-1073741824': '-1 GiB',
         '0': '+/-0 B',
       };
-
-      for (const bytes in table) {
-        if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
-        }
+      for (const [bytes, expected] of Object.entries(table)) {
+        assert.equal(element._formatBytes(Number(bytes)), expected);
       }
     });
 
@@ -590,12 +587,8 @@
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
-        for (const index in element.diffs) {
-          if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.isTrue(
-              element._expandedFiles
-                  .some(f => f.path === element.diffs[index].path)
-          );
+        for (const diff of element.diffs) {
+          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
         }
 
         MockInteractions.keyUpOn(element, 73, 'shift', 'i');
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index b994fa5..f8cd42c 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -65,11 +65,7 @@
     if (this.shadowRoot === null || !this.change) {
       return labels;
     }
-    for (const label in this.permittedLabels) {
-      if (!hasOwnProperty(this.permittedLabels, label)) {
-        continue;
-      }
-
+    for (const label of Object.keys(this.permittedLabels ?? {})) {
       const selectorEl = this.shadowRoot.querySelector(
         `gr-label-score-row[name="${label}"]`
       ) as null | GrLabelScoreRow;
@@ -104,9 +100,10 @@
     labelName: string,
     numberValue?: number
   ) {
-    for (const k in (labels[labelName] as DetailedLabelInfo).values) {
-      if (Number(k) === numberValue) {
-        return k;
+    const detailedInfo = labels[labelName] as DetailedLabelInfo;
+    for (const labelValue of Object.keys(detailedInfo.values)) {
+      if (Number(labelValue) === numberValue) {
+        return labelValue;
       }
     }
     return numberValue;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index 5135e439..ef123c9 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -85,12 +85,10 @@
   });
 
   test('get and set label scores', () => {
-    for (const label in element.permittedLabels) {
-      if (element.permittedLabels.hasOwnProperty(label)) {
-        const row = element.shadowRoot
-            .querySelector('gr-label-score-row[name="' + label + '"]');
-        row.setSelectedValue(-1);
-      }
+    for (const label of Object.keys(element.permittedLabels)) {
+      const row = element.shadowRoot
+          .querySelector('gr-label-score-row[name="' + label + '"]');
+      row.setSelectedValue(-1);
     }
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 070aa2d..6515af0 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -96,7 +96,9 @@
       margin-right: var(--spacing-s);
     }
     .authorLabel {
-      width: 140px;
+      min-width: 130px;
+      --account-max-length: 120px;
+      margin-right: var(--spacing-s);
     }
     .expanded .author {
       cursor: pointer;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
index ab7566f..5134b72 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
@@ -14,24 +14,312 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from 'lit-html';
+import {html, nothing} from 'lit-html';
+import {classMap} from 'lit-html/directives/class-map';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement} from 'lit-element';
+import {customElement, property, css} from 'lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  SubmittedTogetherInfo,
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {pluralize} from '../../../utils/string-util';
+import {ChangeStatus} from '../../../constants/constants';
+
+function isChangeInfo(
+  x: ChangeInfo | RelatedChangeAndCommitInfo | ParsedChangeInfo
+): x is ChangeInfo | ParsedChangeInfo {
+  return (x as ChangeInfo)._number !== undefined;
+}
+
+/** What is the maximum number of shown changes in collapsed list? */
+const MAX_CHANGES_WHEN_COLLAPSED = 3;
 
 @customElement('gr-related-changes-list-experimental')
 export class GrRelatedChangesListExperimental extends GrLitElement {
+  @property()
+  change?: ParsedChangeInfo;
+
+  @property()
+  _submittedTogether?: SubmittedTogetherInfo = {
+    changes: [],
+    non_visible_changes: 0,
+  };
+
+  private readonly restApiService = appContext.restApiService;
+
   static get styles() {
-    return [sharedStyles];
+    return [
+      sharedStyles,
+      css`
+        .title {
+          font-weight: var(--font-weight-bold);
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+        }
+        h4 {
+          display: flex;
+        }
+        /* This is a hacky solution from old gr-related-change-list
+         * TODO(milutin): find layout without needing it
+         */
+        h4:before,
+        gr-related-change:before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .note {
+          color: var(--error-text-color);
+        }
+      `,
+    ];
   }
 
   render() {
-    return html``;
+    const submittedTogetherChanges = this._submittedTogether?.changes ?? [];
+    const countNonVisibleChanges =
+      this._submittedTogether?.non_visible_changes ?? 0;
+    const showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      submittedTogetherChanges.length,
+      submittedTogetherChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      )
+    );
+    return html` <section
+      id="submittedTogether"
+      ?hidden=${!submittedTogetherChanges?.length &&
+      !this._submittedTogether?.non_visible_changes}
+    >
+      <h4 class="title">Submitted together</h4>
+      <gr-related-collapse .length=${submittedTogetherChanges.length}>
+        ${submittedTogetherChanges.map(
+          (relatedChange, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .currentChange="${this._changesEqual(relatedChange, this.change)}"
+              .change="${relatedChange}"
+            ></gr-related-change>`
+        )}
+      </gr-related-collapse>
+      <div class="note" ?hidden=${!countNonVisibleChanges}>
+        (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
+      </div>
+    </section>`;
+  }
+
+  showWhenCollapsedPredicateFactory(length: number, highlightIndex: number) {
+    return (index: number) => {
+      if (highlightIndex === 0) return index <= MAX_CHANGES_WHEN_COLLAPSED - 1;
+      if (highlightIndex === length - 1)
+        return index >= length - MAX_CHANGES_WHEN_COLLAPSED;
+      return (
+        highlightIndex - MAX_CHANGES_WHEN_COLLAPSED + 2 <= index &&
+        index <= highlightIndex + MAX_CHANGES_WHEN_COLLAPSED - 2
+      );
+    };
+  }
+
+  reload() {
+    if (!this.change) return Promise.reject(new Error('change missing'));
+    return this.restApiService
+      .getChangesSubmittedTogether(this.change._number)
+      .then(response => {
+        this._submittedTogether = response;
+      });
+  }
+
+  /**
+   * Do the given objects describe the same change? Compares the changes by
+   * their numbers.
+   */
+  _changesEqual(
+    a?: ChangeInfo | RelatedChangeAndCommitInfo,
+    b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    const aNum = this._getChangeNumber(a);
+    const bNum = this._getChangeNumber(b);
+    return aNum === bNum;
+  }
+
+  /**
+   * Get the change number from either a ChangeInfo (such as those included in
+   * SubmittedTogetherInfo responses) or get the change number from a
+   * RelatedChangeAndCommitInfo (such as those included in a
+   * RelatedChangesInfo response).
+   */
+  _getChangeNumber(
+    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    // Default to 0 if change property is not defined.
+    if (!change) return 0;
+
+    if (isChangeInfo(change)) {
+      return change._number;
+    }
+    return change._change_number;
+  }
+}
+
+@customElement('gr-related-collapse')
+export class GrRelatedCollapse extends GrLitElement {
+  @property()
+  showAll = false;
+
+  @property()
+  length = 0;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        gr-button {
+          display: flex;
+        }
+        gr-button:before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
+          display: flex;
+        }
+        .collapsed ::slotted(gr-related-change) {
+          display: none;
+        }
+        ::slotted(gr-related-change) {
+          display: flex;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const collapsible = this.length > MAX_CHANGES_WHEN_COLLAPSED;
+    const items = html` <div
+      class="${!this.showAll && collapsible ? 'collapsed' : ''}"
+    >
+      <slot></slot>
+    </div>`;
+    let button = nothing;
+    if (collapsible) {
+      if (this.showAll) {
+        button = html`<gr-button link="" @click="${this.toggle}"
+          >Show less</gr-button
+        >`;
+      } else {
+        button = html`<gr-button link="" @click="${this.toggle}"
+          >+ ${this.length - MAX_CHANGES_WHEN_COLLAPSED} more</gr-button
+        >`;
+      }
+    }
+
+    return html`${items}${button}`;
+  }
+
+  private toggle(e: MouseEvent) {
+    e.stopPropagation();
+    this.showAll = !this.showAll;
+  }
+}
+
+@customElement('gr-related-change')
+export class GrRelatedChange extends GrLitElement {
+  @property()
+  change?: ChangeInfo;
+
+  @property()
+  currentChange = false;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        a {
+          display: block;
+        }
+        .changeContainer,
+        a {
+          max-width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .changeContainer {
+          display: flex;
+        }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
+        .submittableCheck {
+          padding-left: var(--spacing-s);
+          color: var(--positive-green-text-color);
+          display: none;
+        }
+        .submittableCheck.submittable {
+          display: inline;
+        }
+        .arrowToCurrentChange {
+          position: absolute;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const change = this.change;
+    if (!change) throw new Error('Missing change');
+    const linkClass = this._computeLinkClass(change);
+    return html`<span
+        role="img"
+        class="arrowToCurrentChange"
+        aria-label="Arrow marking current change"
+        ?hidden=${!this.currentChange}
+        >âž”</span
+      >
+      <div class="changeContainer">
+        <a
+          href="${GerritNav.getUrlForChangeById(
+            change._number,
+            change.project
+          )}"
+          class="${linkClass}"
+          >${change.project}: ${change.branch}: ${change.subject}</a
+        >
+        <span
+          tabindex="-1"
+          title="Submittable"
+          class="submittableCheck ${linkClass}"
+          role="img"
+          aria-label="Submittable"
+          >✓</span
+        >
+      </div> `;
+  }
+
+  _computeLinkClass(change: ChangeInfo) {
+    const statuses = [];
+    if (change.status === ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-related-changes-list-experimental': GrRelatedChangesListExperimental;
+    'gr-related-collapse': GrRelatedCollapse;
+    'gr-related-change': GrRelatedChange;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 3503b9b..4ad99d5 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -26,7 +26,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {ChangeStatus} from '../../../constants/constants';
 
-import {changeIsOpen} from '../../../utils/change-util';
+import {changeIsOpen, getRevisionKey} from '../../../utils/change-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
@@ -395,25 +395,12 @@
     patchNum?: PatchSetNum,
     relatedChanges?: RelatedChangeAndCommitInfo[]
   ) {
-    // Polymer 2: check for undefined
-    if (
-      change === undefined ||
-      patchNum === undefined ||
-      relatedChanges === undefined
-    ) {
-      return undefined;
+    if (!patchNum || !relatedChanges || !change) {
+      return [];
     }
 
     const connected: CommitId[] = [];
-    let changeRevision;
-    if (!change) {
-      return [];
-    }
-    for (const rev in change.revisions) {
-      if (change.revisions[rev]._number === patchNum) {
-        changeRevision = rev;
-      }
-    }
+    const changeRevision = getRevisionKey(change, patchNum);
     const commits = relatedChanges.map(c => c.commit);
     let pos = commits.length - 1;
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index c856f68..ca3161f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -991,7 +991,8 @@
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
         !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
       reviewers.base
-        .filter(r => r._pendingAdd && r._account_id)
+        .filter(r => r._account_id)
+        .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add(r._account_id!));
       // Add owner and uploader, if someone else replies.
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 4547e53..6682bfb 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -314,6 +314,39 @@
     assert.sameMembers([...element._newAttentionSet], [2, 5]);
   });
 
+  test('computeNewAttention when sending wip change for review', () => {
+    const reviewers = {base: [
+      {_account_id: 2},
+      {_account_id: 3},
+    ]};
+    const change = {
+      owner: {_account_id: 1},
+      status: 'NEW',
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    // For an active change there is no reason to add anyone to the set.
+    let user = {_account_id: 1};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], []);
+
+    // If the change is "work in progress" and the owner sends a reply, then
+    // add all reviewers.
+    element.canBeStarted = true;
+    flush();
+    user = {_account_id: 1};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], [2, 3]);
+
+    // ... but not when someone else replies.
+    user = {_account_id: 4};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], []);
+  });
+
   test('computeNewAttentionAccounts', () => {
     element._reviewers = [
       {_account_id: 123, display_name: 'Ernie'},
@@ -968,8 +1001,8 @@
       };
     };
     const checkObjEmpty = function(obj) {
-      for (const prop in obj) {
-        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      for (const prop of Object.keys(obj)) {
+        if (obj[prop].length) { return false; }
       }
       return true;
     };
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 254ecca..30931c3 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -231,7 +231,7 @@
     }
     let result: AccountInfo[] = [];
     const reviewers = changeRecord.base;
-    for (const key in reviewers) {
+    for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
index ccdcf8d..c7c5efa 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -27,6 +27,11 @@
     }
     .container {
       display: block;
+      /* line-height-normal for the chips, 2px for the chip border, spacing-s
+         for the gap between lines, negative bottom margin for eliminating the
+         gap after the last line */
+      line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
+      margin-bottom: calc(0px - var(--spacing-s));
     }
     .addReviewer iron-icon {
       color: inherit;
@@ -39,11 +44,14 @@
       top: 1px;
     }
     gr-button {
+      line-height: var(--line-height-normal);
       --gr-button: {
         padding: 0px 0px;
       }
     }
     gr-account-chip {
+      line-height: var(--line-height-normal);
+      vertical-align: top;
       display: inline-block;
     }
   </style>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 4976503..7cea964 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -31,9 +31,17 @@
   PolymerDeepPropertyChange,
 } from '@polymer/polymer/interfaces';
 import {ChangeInfo} from '../../../types/common';
-import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isDraft,
+  UIRobot,
+  isUnresolved,
+  isDraftThread,
+} from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {fireThreadListModifiedEvent} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {appContext} from '../../../services/app-context';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -91,6 +99,19 @@
   @property({type: Boolean})
   hideToggleButtons = false;
 
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  flagsService = appContext.flagsService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   _computeShowDraftToggle(loggedIn?: boolean) {
     return loggedIn ? 'show' : '';
   }
@@ -431,6 +452,37 @@
     return !!side;
   }
 
+  _handleOnlyUnresolved() {
+    this.unresolvedOnly = true;
+    this._draftsOnly = false;
+  }
+
+  _handleOnlyDrafts() {
+    this._draftsOnly = true;
+    this.unresolvedOnly = false;
+  }
+
+  _handleAllComments() {
+    this._draftsOnly = false;
+    this.unresolvedOnly = false;
+  }
+
+  _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
+    return !draftsOnly && !unresolvedOnly;
+  }
+
+  _countUnresolved(threads?: CommentThread[]) {
+    return threads?.filter(isUnresolved).length ?? 0;
+  }
+
+  _countAllThreads(threads?: CommentThread[]) {
+    return threads?.length ?? 0;
+  }
+
+  _countDrafts(threads?: CommentThread[]) {
+    return threads?.filter(isDraftThread).length ?? 0;
+  }
+
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index 2c21b4a..28e8da8 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -29,12 +29,11 @@
     }
     .header {
       align-items: center;
-      background-color: var(--table-header-background-color);
+      background-color: var(--background-color-primary);
       border-bottom: 1px solid var(--border-color);
       border-top: 1px solid var(--border-color);
       display: flex;
       justify-content: left;
-      min-height: 3.2em;
       padding: var(--spacing-m) var(--spacing-l);
     }
     .toggleItem.draftToggle {
@@ -65,27 +64,72 @@
       box-shadow: none;
       padding-left: var(--spacing-m);
     }
+    .header .categoryRadio {
+      height: 18px;
+      width: 18px;
+    }
+    .header label {
+      padding-left: 8px;
+      margin-right: 16px;
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
-      <div class="toggleItem">
-        <paper-toggle-button
-          id="unresolvedToggle"
-          checked="{{!unresolvedOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-          >All comments</paper-toggle-button
+      <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+        <div class="toggleItem">
+          <paper-toggle-button
+            id="unresolvedToggle"
+            checked="{{!unresolvedOnly}}"
+            on-tap="_onTapUnresolvedToggle"
+            >All comments</paper-toggle-button
+          >
+        </div>
+        <div
+          class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
         >
-      </div>
-      <div
-        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
-      >
-        <paper-toggle-button
-          id="draftToggle"
-          checked="{{_draftsOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-          >Comments with drafts</paper-toggle-button
-        >
-      </div>
+          <paper-toggle-button
+            id="draftToggle"
+            checked="{{_draftsOnly}}"
+            on-tap="_onTapUnresolvedToggle"
+            >Comments with drafts</paper-toggle-button
+          >
+        </div>
+      </template>
+      <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+          <input
+            class="categoryRadio"
+            id="unresolvedRadio"
+            name="filterComments"
+            type="radio"
+            on-click="_handleOnlyUnresolved"
+            checked$="[[unresolvedOnly]]"
+          />
+          <label for="unresolvedRadio">
+            Unresolved ([[_countUnresolved(threads)]])
+          </label>
+          <input
+            class="categoryRadio"
+            id="draftsRadio"
+            name="filterComments"
+            type="radio"
+            on-click="_handleOnlyDrafts"
+            checked$="[[_draftsOnly]]"
+          />
+          <label for="draftsRadio">
+            Drafts ([[_countDrafts(threads)]])
+          </label>
+          <input
+            class="categoryRadio"
+            id="allRadio"
+            name="filterComments"
+            type="radio"
+            on-click="_handleAllComments"
+            checked$="[[_showAllComments(_draftsOnly, unresolvedOnly)]]"
+          />
+          <label for="all">
+            All ([[_countAllThreads(threads)]])
+          </label>
+      </template>
     </div>
   </template>
   <div id="threads">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index e800fbd..68ff67b 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -19,22 +19,11 @@
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Category, CheckRun, Link, RunStatus, Tag} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
-import {assertNever} from '../../utils/common-util';
 import {RunResult} from '../../services/checks/checks-model';
-import {hasCompletedWithoutResults} from '../../services/checks/checks-util';
-
-export function iconForCategory(category: Category) {
-  switch (category) {
-    case Category.ERROR:
-      return 'error';
-    case Category.INFO:
-      return 'info-outline';
-    case Category.WARNING:
-      return 'warning';
-    default:
-      assertNever(category, `Unsupported category: ${category}`);
-  }
-}
+import {
+  hasCompletedWithoutResults,
+  iconForCategory,
+} from '../../services/checks/checks-util';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -350,7 +339,10 @@
     if (runs.length === 0) return;
     return html`
       <h3 class="categoryHeader heading-3">
-        <iron-icon icon="gr-icons:check-circle" class="success"></iron-icon>
+        <iron-icon
+          icon="gr-icons:check-circle-outline"
+          class="success"
+        ></iron-icon>
         Success
       </h3>
       <table class="resultsTable">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 2939089..6638c5f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -15,16 +15,15 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
+import {classMap} from 'lit-html/directives/class-map';
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {CheckRun, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
-import {iconForCategory} from './gr-checks-results';
 import {
   compareByWorstCategory,
-  worstCategory,
+  iconForRun,
 } from '../../services/checks/checks-util';
-import {assertNever} from '../../utils/common-util';
 import {
   allRuns$,
   fakeRun0,
@@ -35,31 +34,115 @@
   updateStateSetResults,
 } from '../../services/checks/checks-model';
 
-function renderRun(run: CheckRun) {
-  return html`<div class="runChip ${iconClass(run)}">
-    ${renderIcon(run)}
-    <span>${run.checkName}</span>
-  </div>`;
+/* The RunSelectedEvent is only used locally to communicate from <gr-checks-run>
+   to its <gr-checks-runs> parent. */
+
+interface RunSelectedEventDetail {
+  checkName: string;
 }
 
-function renderIcon(run: CheckRun) {
-  const icon = iconClass(run);
-  if (!icon) return;
-  return html`<iron-icon icon="gr-icons:${icon}" class="${icon}"></iron-icon>`;
+type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'run-selected': RunSelectedEvent;
+  }
 }
 
-function iconClass(run: CheckRun) {
-  const category = worstCategory(run);
-  if (category) return iconForCategory(category);
-  switch (run.status) {
-    case RunStatus.COMPLETED:
-      return 'check-circle';
-    case RunStatus.RUNNABLE:
-      return 'placeholder';
-    case RunStatus.RUNNING:
-      return 'timelapse';
-    default:
-      assertNever(run.status, `Unsupported status: ${run.status}`);
+function fireRunSelected(target: EventTarget, checkName: string) {
+  target.dispatchEvent(
+    new CustomEvent('run-selected', {
+      detail: {checkName},
+      composed: false,
+      bubbles: false,
+    })
+  );
+}
+
+@customElement('gr-checks-run')
+export class GrChecksRun extends GrLitElement {
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          --thick-border: 6px;
+        }
+        .chip {
+          display: block;
+          font-weight: var(--font-weight-bold);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s) var(--spacing-m);
+          margin-top: var(--spacing-s);
+          cursor: default;
+        }
+        .chip.error {
+          border-left: var(--thick-border) solid var(--error-foreground);
+        }
+        .chip.warning {
+          border-left: var(--thick-border) solid var(--warning-foreground);
+        }
+        .chip.info-outline {
+          border-left: var(--thick-border) solid var(--info-foreground);
+        }
+        .chip.check-circle-outline {
+          border-left: var(--thick-border) solid var(--success-foreground);
+        }
+        .chip.timelapse {
+          border-left: var(--thick-border) solid var(--border-color);
+        }
+        .chip.placeholder {
+          border-left: var(--thick-border) solid var(--border-color);
+        }
+        .chip.error iron-icon {
+          color: var(--error-foreground);
+        }
+        .chip.warning iron-icon {
+          color: var(--warning-foreground);
+        }
+        .chip.info-outline iron-icon {
+          color: var(--info-foreground);
+        }
+        .chip.check-circle-outline iron-icon {
+          color: var(--success-foreground);
+        }
+        /* Additional 'div' for increased specificity. */
+        div.chip.selected {
+          border: 1px solid var(--selected-foreground);
+          background-color: var(--selected-background);
+          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
+        }
+        div.chip.selected iron-icon {
+          color: var(--selected-foreground);
+        }
+      `,
+    ];
+  }
+
+  @property()
+  run!: CheckRun;
+
+  @property()
+  selected = false;
+
+  render() {
+    const icon = this.selected ? 'check-circle' : iconForRun(this.run);
+    const classes = {chip: true, [icon]: true, selected: this.selected};
+
+    return html`
+      <div @click="${this._handleChipClick}" class="${classMap(classes)}">
+        <iron-icon icon="gr-icons:${icon}"></iron-icon>
+        <span>${this.run.checkName}</span>
+      </div>
+    `;
+  }
+
+  _handleChipClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireRunSelected(this, this.run.checkName);
   }
 }
 
@@ -68,6 +151,8 @@
   @property()
   runs: CheckRun[] = [];
 
+  private selectedRuns = new Set<string>();
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
@@ -85,43 +170,6 @@
           padding-top: var(--spacing-l);
           text-transform: capitalize;
         }
-        .runChip {
-          font-weight: var(--font-weight-bold);
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          padding: var(--spacing-s) var(--spacing-m);
-          margin-top: var(--spacing-s);
-        }
-        .runChip.error {
-          border-left: 6px solid var(--error-foreground);
-        }
-        .runChip.warning {
-          border-left: 6px solid var(--warning-foreground);
-        }
-        .runChip.info-outline {
-          border-left: 6px solid var(--info-foreground);
-        }
-        .runChip.check-circle {
-          border-left: 6px solid var(--success-foreground);
-        }
-        .runChip.timelapse {
-          border-left: 6px solid var(--border-color);
-        }
-        .runnable .runChip.placeholder iron-icon {
-          display: none;
-        }
-        .runChip.error iron-icon {
-          color: var(--error-foreground);
-        }
-        .runChip.warning iron-icon {
-          color: var(--warning-foreground);
-        }
-        .runChip.info-outline iron-icon {
-          color: var(--info-foreground);
-        }
-        .runChip.check-circle iron-icon {
-          color: var(--success-foreground);
-        }
         .testing {
           margin-top: var(--spacing-xxl);
           color: var(--deemphasized-text-color);
@@ -197,14 +245,34 @@
     return html`
       <div class="${status.toLowerCase()}">
         <h3 class="statusHeader heading-3">${status.toLowerCase()}</h3>
-        ${runs.map(renderRun)}
+        ${runs.map(run => this.renderRun(run))}
       </div>
     `;
   }
+
+  renderRun(run: CheckRun) {
+    const selected = this.selectedRuns.has(run.checkName);
+    return html`<gr-checks-run
+      .run="${run}"
+      .selected="${selected}"
+      @run-selected="${this.handleRunSelected}"
+    ></gr-checks-run>`;
+  }
+
+  handleRunSelected(e: RunSelectedEvent) {
+    const checkName = e.detail.checkName;
+    if (this.selectedRuns.has(checkName)) {
+      this.selectedRuns.delete(checkName);
+    } else {
+      this.selectedRuns.add(checkName);
+    }
+    this.requestUpdate();
+  }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
+    'gr-checks-run': GrChecksRun;
     'gr-checks-runs': GrChecksRuns;
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 71d92d0..a3a648e 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -33,7 +33,12 @@
 import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
 import {AccountId} from '../../../types/common';
 import {EventType} from '../../../utils/event-util';
-import {NetworkErrorEvent, ServerErrorEvent} from '../../../types/events';
+import {
+  NetworkErrorEvent,
+  ServerErrorEvent,
+  ShowAlertEvent,
+} from '../../../types/events';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -274,12 +279,14 @@
     return err;
   }
 
-  _handleShowAlert(e: CustomEvent) {
+  _handleShowAlert(e: ShowAlertEvent) {
     this._showAlert(
       e.detail.message,
       e.detail.action,
       e.detail.callback,
-      e.detail.dismissOnNavigation
+      e.detail.dismissOnNavigation,
+      undefined,
+      e.detail.showDismiss
     );
   }
 
@@ -299,7 +306,8 @@
     actionText?: string,
     actionCallback?: () => void,
     dismissOnNavigation?: boolean,
-    type?: ErrorType
+    type?: ErrorType,
+    showDismiss?: boolean
   ) {
     if (this._alertElement) {
       // check priority before hiding
@@ -317,7 +325,7 @@
         HIDE_ALERT_TIMEOUT_MS
       );
     }
-    const el = this._createToastAlert();
+    const el = this._createToastAlert(showDismiss);
     el.show(text, actionText, actionCallback);
     this._alertElement = el;
     this.fire('iron-announce', {text: `Alert: ${text}`}, {bubbles: true});
@@ -363,9 +371,10 @@
     }
   }
 
-  _createToastAlert() {
+  _createToastAlert(showDismiss?: boolean) {
     const el = document.createElement('gr-alert');
     el.toast = true;
+    el.showDismiss = !!showDismiss;
     return el;
   }
 
@@ -438,7 +447,7 @@
   }
 
   _reloadPage() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _createLoginPopup() {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 8a23004..3a76112 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -67,6 +67,7 @@
 import {GerritView, updateState} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
 import {addQuotesWhen} from '../../../utils/string-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const RoutePattern = {
   ROOT: '/',
@@ -1725,7 +1726,7 @@
    * by the catchall _handleDefaultRoute handler.
    */
   _handlePassThroughRoute() {
-    location.reload();
+    windowLocationReload();
   }
 
   /**
@@ -1762,7 +1763,7 @@
 
   _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
     if (data.params[1]) {
-      location.reload();
+      windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
       this._redirect('/Documentation/index.html');
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 1085fbc..468c8ca 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -32,7 +32,6 @@
   FileInfo,
   ParentPatchSetNum,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   Comment,
   CommentMap,
@@ -149,15 +148,11 @@
     ];
     const commentMap: CommentMap = {};
     for (const response of responses) {
-      for (const path in response) {
+      for (const [path, comments] of Object.entries(response)) {
         if (
-          hasOwnProperty(response, path) &&
-          response[path].some(c => {
+          comments.some(c => {
             // If don't care about patch range, we know that the path exists.
-            if (!patchRange) {
-              return true;
-            }
-            return isInPatchRange(c, patchRange);
+            return !patchRange || isInPatchRange(c, patchRange);
           })
         ) {
           commentMap[path] = true;
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 229ca76..0ae0e84 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
@@ -866,7 +866,7 @@
     });
 
     test('getSectionsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(2)');
+      const section = outputEl.querySelector('stub:nth-of-type(3)');
       const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
       assert.equal(sections.length, 1);
       assert.strictEqual(sections[0], section);
@@ -874,8 +874,8 @@
 
     test('getSectionsByLineRange over diff', () => {
       const section = [
-        outputEl.querySelector('stub:nth-of-type(2)'),
         outputEl.querySelector('stub:nth-of-type(3)'),
+        outputEl.querySelector('stub:nth-of-type(4)'),
       ];
       const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
       assert.equal(sections.length, 2);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 1d95057..da1b928 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -541,7 +541,10 @@
       td.classList.add('lineNum');
       td.dataset['value'] = number.toString();
 
-      if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      if (
+        (this._prefs.show_file_comment_button === false && number === 'FILE') ||
+        number === 'LOST'
+      ) {
         return td;
       }
 
@@ -589,7 +592,7 @@
     }
     td.classList.add(line.type);
 
-    if (line.beforeNumber !== 'FILE') {
+    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
       const lineLimit = !this._prefs.line_wrapping
         ? this._prefs.line_length
         : Infinity;
@@ -614,9 +617,8 @@
       }
 
       td.appendChild(contentText);
-    } else {
-      td.classList.add('file');
-    }
+    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
+    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
 
     return td;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 4160d38..60b82da 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -606,7 +606,7 @@
 
   test('_findRowByNumberAndFile', () => {
     // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[8];
+    const row = diffElement.root.querySelectorAll('tr')[9];
 
     // It should be line 8 on the right, but line 5 on the left.
     assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index fd5c19d..d5c7d8e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -357,7 +357,7 @@
     const side = this.diffBuilder.getSideByLineEl(lineEl);
     if (!side) return null;
     const line = this.diffBuilder.getLineNumberByChild(lineEl);
-    if (!line || line === FILE) return null;
+    if (!line || line === FILE || line === 'LOST') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
     const contentText = contentTd.querySelector('.contentText');
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 89ee170..1b1e71c 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
@@ -840,7 +840,10 @@
   _createThreadElement(thread: CommentThread) {
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute('slot', `${thread.diffSide}-${thread.line}`);
+    threadEl.setAttribute(
+      'slot',
+      `${thread.diffSide}-${thread.line || 'LOST'}`
+    );
     threadEl.comments = thread.comments;
     threadEl.diffSide = thread.diffSide;
     threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
@@ -861,8 +864,9 @@
     threadEl.patchNum = thread.patchNum;
     threadEl.showPatchset = false;
     threadEl.showPortedComment = !!thread.ported;
+    if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
     // GrCommentThread does not understand 'FILE', but requires undefined.
-    threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
+    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
     const threadDiscardListener = (e: Event) => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index a0584b6..034081d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -22,6 +22,7 @@
   GrDiffLineType,
   FILE,
   Highlights,
+  LineNumber,
 } from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
@@ -150,7 +151,8 @@
     this.cancel();
 
     this.groups = [];
-    this.push('groups', this._makeFileComments());
+    this.push('groups', this._makeGroup('LOST'));
+    this.push('groups', this._makeGroup(FILE));
 
     // If it's a binary diff, we won't be rendering hunks of text differences
     // so finish processing.
@@ -450,10 +452,10 @@
     return line;
   }
 
-  _makeFileComments() {
+  _makeGroup(number: LineNumber) {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
-    line.beforeNumber = FILE;
-    line.afterNumber = FILE;
+    line.beforeNumber = number;
+    line.afterNumber = number;
     return new GrDiffGroup(GrDiffGroupType.BOTH, [line]);
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index f5cbcc0..b8f7498 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -73,7 +73,7 @@
 
       return element.process(content).then(() => {
         const groups = element.groups;
-
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
 
         let group = groups[0];
@@ -133,6 +133,7 @@
 
       return element.process(content).then(() => {
         const groups = element.groups;
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         assert.equal(groups[0].type, GrDiffGroupType.BOTH);
         assert.equal(groups[0].lines.length, 1);
@@ -153,6 +154,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
 
@@ -185,6 +187,7 @@
         await element.process(content);
 
         const groups = element.groups;
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
 
@@ -231,6 +234,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
 
@@ -252,6 +256,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -283,6 +288,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -324,6 +330,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -411,6 +418,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -450,6 +458,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -479,6 +488,7 @@
       await element.process(content);
 
       const groups = element.groups;
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
       // group[1] is the chunk with a
@@ -744,12 +754,12 @@
       element._isScrolling = true;
       element.process(content);
       // Just the files group - no more processing during scrolling.
-      assert.equal(element.groups.length, 1);
+      assert.equal(element.groups.length, 2);
 
       element._isScrolling = false;
       element.process(content);
       // More groups have been processed. How many does not matter here.
-      assert.isAtLeast(element.groups.length, 2);
+      assert.isAtLeast(element.groups.length, 3);
     });
 
     test('image diffs', () => {
@@ -762,7 +772,7 @@
       const content = _.times(200, _.constant(contentRow));
       sinon.stub(element, 'async');
       element.process(content, true);
-      assert.equal(element.groups.length, 1);
+      assert.equal(element.groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
       assert.equal(element.groups[0].lines.length, 1);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 1e9b1cc..62b34e9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -85,7 +85,6 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {LineOfInterest} from '../gr-diff/gr-diff';
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
@@ -896,9 +895,8 @@
     let baseCommit: CommitId | undefined;
     if (!this._change) return;
     if (!this._patchRange || !this._patchRange.patchNum) return;
-    for (const commitSha in this._change.revisions) {
-      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
-      const revision = this._change.revisions[commitSha];
+    const revisions = this._change.revisions ?? {};
+    for (const [commitSha, revision] of Object.entries(revisions)) {
       const patchNum = revision._number;
       if (patchNum === this._patchRange.patchNum) {
         commit = commitSha as CommitId;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 8fe06f8..34e74b6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -812,10 +812,7 @@
     }
 
     test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus in ChangeStatus) {
-        if (!ChangeStatus.hasOwnProperty(changeStatus)) {
-          continue;
-        }
+      for (const changeStatus of Object.keys(ChangeStatus)) {
         assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
             `loggedIn: false, changeStatus: ${changeStatus}`);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index 3fd7775..ba6fe7e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -324,7 +324,12 @@
   }
 
   _updateRange(line: GrDiffLine) {
-    if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') {
+    if (
+      line.beforeNumber === 'FILE' ||
+      line.afterNumber === 'FILE' ||
+      line.beforeNumber === 'LOST' ||
+      line.afterNumber === 'LOST'
+    ) {
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 1e5a8d3..5edd353 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -48,12 +48,14 @@
   const lineNumberStr = lineEl.getAttribute('data-value');
   if (!lineNumberStr) return null;
   if (lineNumberStr === FILE) return FILE;
+  if (lineNumberStr === 'LOST') return 'LOST';
   const lineNumber = Number(lineNumberStr);
   return Number.isInteger(lineNumber) ? lineNumber : null;
 }
 
 export function getLine(threadEl: HTMLElement): LineNumber {
   const lineAtt = threadEl.getAttribute('line-num');
+  if (lineAtt === 'LOST') return lineAtt;
   if (!lineAtt || lineAtt === 'FILE') return FILE;
   const line = Number(lineAtt);
   if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index b85d948..f6f80b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -67,6 +67,7 @@
 import * as shadow from 'shadow-selection-polyfill/shadow.js';
 
 import {CreateCommentEventDetail as CreateCommentEventDetailApi} from '../../../api/diff';
+import {isSafari} from '../../../utils/dom-util';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -355,11 +356,13 @@
   _getShadowOrDocumentSelection() {
     // When using native shadow DOM, the selection returned by
     // document.getSelection() cannot reference the actual DOM elements making
-    // up the diff, because they are in the shadow DOM of the gr-diff element.
-    // This takes the shadow DOM selection if one exists.
+    // up the diff in Safari because they are in the shadow DOM of the gr-diff
+    // element. This takes the shadow DOM selection if one exists.
     return this.root instanceof ShadowRoot && this.root.getSelection
       ? this.root.getSelection()
-      : shadow.getRange(this.root);
+      : isSafari()
+      ? shadow.getRange(this.root)
+      : document.getSelection();
   }
 
   _observeNodes() {
@@ -518,8 +521,9 @@
       );
       this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
     } else if (
-      el.classList.contains('lineNum') ||
-      el.classList.contains('lineNumButton')
+      el.getAttribute('data-value') !== 'LOST' &&
+      (el.classList.contains('lineNum') ||
+        el.classList.contains('lineNumButton'))
     ) {
       this.addDraftAtLine(el);
     } else if (
@@ -817,6 +821,9 @@
         }
         const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
+        if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
+          contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
+        }
         const threadGroupEl = this._getOrCreateThreadGroup(
           contentEl,
           commentSide
@@ -862,6 +869,17 @@
     });
   }
 
+  _portedCommentsWithoutRangeMessage() {
+    const div = document.createElement('div');
+    const icon = document.createElement('iron-icon');
+    icon.setAttribute('icon', 'gr-icons:info');
+    div.appendChild(icon);
+    const span = document.createElement('span');
+    span.innerText = 'Original comment position not found in this patchset';
+    div.appendChild(span);
+    return div;
+  }
+
   _unobserveIncrementalNodes() {
     if (this._incrementalNodeObserver) {
       (dom(this) as PolymerDomWrapper).unobserveNodes(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 291f842..b0f48ce 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -427,6 +427,13 @@
     .target-row td.blame {
       background: var(--diff-selection-background-color);
     }
+    td.lost div {
+      background-color: var(--blue-50);
+      padding: var(--spacing-s);
+    }
+    td.lost iron-icon {
+      margin-right: var(--spacing-s);
+    }
     col.blame {
       display: none;
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index 04bb3d2..da29b85 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -212,9 +212,8 @@
    */
   annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
     if (!this.enabled) return;
-    if (line.beforeNumber === FILE) return;
-    if (line.afterNumber === FILE) return;
-
+    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
+    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
     // Determine the side.
     let side;
     if (
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 72e24f7..cdc9f98 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -79,6 +79,7 @@
 import {ViewState} from '../types/types';
 import {EventType} from '../utils/event-util';
 import {GerritView} from '../services/router/router-model';
+import {windowLocationReload} from '../utils/dom-util';
 
 interface ErrorInfo {
   text: string;
@@ -241,7 +242,7 @@
     // Ideally individual views should handle this event and respond with a soft
     // reload. This is a catch-all for all views that cannot or have not
     // implemented that.
-    this.addEventListener('reload', () => window.location.reload());
+    this.addEventListener('reload', () => windowLocationReload());
   }
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
index c41b551..1c606cd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
@@ -172,12 +172,10 @@
     }
 
     function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-      for (const key in elements) {
-        if (elements.hasOwnProperty(key)) {
-          assert.equal(
-              getComputedStyle(elements[key]).getPropertyValue('display'),
-              expectedDisplayValues[key]);
-        }
+      for (let i = 0; i < elements.length; i++) {
+        assert.equal(
+            getComputedStyle(elements[i]).getPropertyValue('display'),
+            expectedDisplayValues[i]);
       }
     }
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
index ce9c106..1088585 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
@@ -61,10 +61,8 @@
   });
 
   teardown(() => {
-    for (const eventType in _listeners) {
-      if (_listeners.hasOwnProperty(eventType)) {
-        element.removeEventListener(eventType, _listeners[eventType]);
-      }
+    for (const [eventType, listeners] of Object.entries(_listeners)) {
+      element.removeEventListener(eventType, listeners);
     }
   });
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 36444a3..a45e160 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -121,14 +121,8 @@
   _getProjectSuggestions(input: string) {
     return this.restApiService.getSuggestedProjects(input).then(response => {
       const projects: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        projects.push({
-          name: key,
-          value: response[key].id,
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        projects.push({name, value: project.id});
       }
       return projects;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 5cef814..3f8fe6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -31,6 +31,7 @@
 import {hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends GestureEventListeners(
@@ -213,7 +214,7 @@
     if (!this.account._account_id) return;
 
     this.dispatchEvent(
-      new CustomEvent('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>('show-alert', {
         detail: {
           message: 'Saving attention set update ...',
           dismissOnNavigation: true,
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index e5806f0..a0fddcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -62,6 +62,9 @@
   @property({type: Boolean})
   _hideActionButton?: boolean;
 
+  @property({type: Boolean})
+  showDismiss = false;
+
   @property()
   _boundTransitionEndHandler?: (
     this: HTMLElement,
@@ -105,6 +108,10 @@
     }
   }
 
+  _handleDismissTap() {
+    this.hide();
+  }
+
   _hasZeroTransitionDuration() {
     const style = window.getComputedStyle(this);
     // transitionDuration is always given in seconds.
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
index d2aed40..b66a1dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -74,6 +74,10 @@
       hidden$="[[_hideActionButton]]"
       on-click="_handleActionTap"
       >[[actionText]]</gr-button
+    ><template is="dom-if" if="[[showDismiss]]"
+      ><gr-button link="" class="action" on-click="_handleDismissTap"
+        >Dismiss</gr-button
+      ></template
     >
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index c8b3be2..cb64001 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -277,6 +277,7 @@
       (this.comments.length && this.comments[0].side === 'PARENT') ||
       isDraft(this.comments[0])
     ) {
+      if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
       return GerritNav.getUrlForDiffById(
         changeNum,
         projectName,
@@ -503,7 +504,7 @@
       __draftID: Math.random().toString(36),
       __date: new Date(),
     };
-
+    if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
     // For replies, always use same meta info as root.
     if (this.comments && this.comments.length >= 1) {
       const rootComment = this.comments[0];
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 2537c1a..e825d21 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -25,6 +25,8 @@
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -67,7 +69,7 @@
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
-  @property({type: Boolean, observer: '_editingChanged'})
+  @property({type: Boolean, observer: '_editingChanged', notify: true})
   editing = false;
 
   @property({type: Boolean})
@@ -77,6 +79,29 @@
   @property({type: String})
   storageKey?: string;
 
+  /** If false, then the "Show more" button was used to expand. */
+  @property({type: Boolean})
+  _commitCollapsed = true;
+
+  @property({type: Boolean})
+  commitCollapsible = true;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)',
+  })
+  _hideShowAllContainer = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHideShowAllButton(commitCollapsible, editing)',
+  })
+  _hideShowAllButton = false;
+
+  @property({type: Boolean})
+  hideEditCommitMessage?: boolean;
+
   @property({
     type: Boolean,
     computed: '_computeSaveDisabled(disabled, content, _newContent)',
@@ -86,8 +111,21 @@
   @property({type: String, observer: '_newContentChanged'})
   _newContent?: string;
 
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
   private readonly storage = new GrStorage();
 
+  private readonly flagsService = appContext.flagsService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   _contentChanged() {
     /* A changed content means that either a different change has been loaded
      * or new content was saved. Either way, let's reset the component.
@@ -186,4 +224,37 @@
     this.editing = false;
     fireEvent(this, 'editable-content-cancel');
   }
+
+  _computeCollapseText(collapsed: boolean) {
+    return collapsed ? 'Show all' : 'Show less';
+  }
+
+  _toggleCommitCollapsed() {
+    this._commitCollapsed = !this._commitCollapsed;
+    if (this._commitCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _computeHideShowAllContainer(
+    hideEditCommitMessage?: boolean,
+    _hideShowAllButton?: boolean,
+    editing?: boolean
+  ) {
+    if (editing) return false;
+    return _hideShowAllButton && hideEditCommitMessage;
+  }
+
+  _computeHideShowAllButton(commitCollapsible?: boolean, editing?: boolean) {
+    return !commitCollapsible || editing;
+  }
+
+  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    return collapsible && collapsed;
+  }
+
+  _handleEditCommitMessage() {
+    this.editing = true;
+    this.focusTextarea();
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index fa18761..6605394 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -31,13 +31,19 @@
       box-shadow: var(--elevation-level-1);
       padding: var(--spacing-m);
     }
-    :host([collapsed]) .viewer {
+    :host([collapsed]) .viewer,
+    .viewer[collapsed] {
       max-height: var(--collapsed-max-height, 300px);
       overflow: hidden;
     }
+    .editor.new-change-summary-true iron-autogrow-textarea,
+    .viewer.new-change-summary-true {
+      min-height: 160px;
+    }
     .editor iron-autogrow-textarea {
       background-color: var(--view-background-color);
       width: 100%;
+      display: block;
 
       /* You have to also repeat everything from shared-styles here, because
            you can only *replace* --iron-autogrow-textarea vars as a whole. */
@@ -52,23 +58,103 @@
       display: flex;
       justify-content: space-between;
     }
+    .show-all-container {
+      background-color: var(--view-background-color);
+      display: flex;
+      justify-content: flex-end;
+      margin-bottom: 8px;
+      border-top-width: 1px;
+      border-top-style: solid;
+      border-radius: 0 0 4px 4px;
+      border-color: var(--border-color);
+      box-shadow: var(--elevation-level-1);
+    }
+    .show-all-container .show-all-button {
+      margin-right: auto;
+    }
+    .show-all-container iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    .cancel-button {
+      margin-right: var(--spacing-l);
+    }
+    .save-button {
+      margin-right: var(--spacing-xs);
+    }
   </style>
-  <div class="viewer" hidden$="[[editing]]">
+  <div
+    class$="viewer new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
+    hidden$="[[editing]]"
+    collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, commitCollapsible)]]"
+  >
     <slot></slot>
   </div>
-  <div class="editor" hidden$="[[!editing]]">
-    <iron-autogrow-textarea
-      autocomplete="on"
-      bind-value="{{_newContent}}"
-      disabled="[[disabled]]"
-    ></iron-autogrow-textarea>
-    <div class="editButtons">
-      <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]"
-        >Save</gr-button
-      >
-      <gr-button on-click="_handleCancel" disabled="[[disabled]]"
-        >Cancel</gr-button
-      >
+  <div
+    class$="editor new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
+    hidden$="[[!editing]]"
+  >
+    <div>
+      <iron-autogrow-textarea
+        autocomplete="on"
+        bind-value="{{_newContent}}"
+        disabled="[[disabled]]"
+      ></iron-autogrow-textarea>
+      <div class="editButtons" hidden$="[[_isNewChangeSummaryUiEnabled]]">
+        <gr-button
+          primary=""
+          on-click="_handleSave"
+          disabled="[[_saveDisabled]]"
+          >Save</gr-button
+        >
+        <gr-button on-click="_handleCancel" disabled="[[disabled]]"
+          >Cancel</gr-button
+        >
+      </div>
     </div>
   </div>
+  <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+    <div class="show-all-container" hidden$="[[_hideShowAllContainer]]">
+      <gr-button
+        link=""
+        class="show-all-button"
+        on-click="_toggleCommitCollapsed"
+        hidden$="[[_hideShowAllButton]]"
+        ><iron-icon
+          icon="gr-icons:expand-more"
+          hidden$="[[!_commitCollapsed]]"
+        ></iron-icon
+        ><iron-icon
+          icon="gr-icons:expand-less"
+          hidden$="[[_commitCollapsed]]"
+        ></iron-icon>
+        [[_computeCollapseText(_commitCollapsed)]]
+      </gr-button>
+      <gr-button
+        link=""
+        class="edit-commit-message"
+        title="Edit commit message"
+        on-click="_handleEditCommitMessage"
+        hidden$="[[hideEditCommitMessage]]"
+        ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
+      >
+      <div class="editButtons" hidden$="[[!editing]]">
+        <gr-button
+          link=""
+          class="cancel-button"
+          on-click="_handleCancel"
+          disabled="[[disabled]]"
+          >Cancel</gr-button
+        >
+        <gr-button
+          class="save-button"
+          primary=""
+          on-click="_handleSave"
+          disabled="[[_saveDisabled]]"
+          >Save</gr-button
+        >
+      </div>
+    </div>
+  </template>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
index a0481ae..b99b119 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
@@ -105,7 +105,7 @@
 
       assert.equal(element._newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.firstCall.args[0].type, 'show-alert');
     });
 
     test('editing toggled to true, has no stored data', () => {
@@ -114,7 +114,7 @@
       element.editing = true;
 
       assert.equal(element._newContent, 'current content');
-      assert.isFalse(dispatchSpy.called);
+      assert.equal(dispatchSpy.firstCall.args[0].type, 'editing-changed');
     });
 
     test('edits are cached', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 78b6cda..94f99d3 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -28,12 +28,7 @@
   pushScrollLock,
   removeScrollLock,
 } from '@polymer/iron-overlay-behavior/iron-scroll-manager';
-
-interface ShowAlertEventDetail {
-  message: string;
-  dismissOnNavigation?: boolean;
-}
-
+import {ShowAlertEventDetail} from '../../../types/events';
 interface ReloadEventDetail {
   clearPatchset?: boolean;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index e499b8b..0a3ef5b 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -79,8 +79,10 @@
       <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check-circle-->
-      <g id="check-circle"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle-->
+      <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle_outline-->
+      <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index ffdf710..d4a2bd6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -16,8 +16,10 @@
  */
 
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
+import {ShowAlertEventDetail} from '../../../types/events';
 import {PluginApi} from '../../plugins/gr-plugin-types';
 import {UIActionInfo} from './gr-change-actions-js-api';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 interface GrPopupInterface {
   close(): void;
@@ -56,7 +58,7 @@
   }
 
   refresh() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   textfield(): HTMLElement {
@@ -117,7 +119,7 @@
       .then(onSuccess)
       .catch((error: unknown) => {
         document.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message: `Plugin network error: ${error}`,
             },
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index bdca0ed..aacef0e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -27,6 +27,7 @@
 import {PluginApi} from '../../plugins/gr-plugin-types';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -248,7 +249,7 @@
   _failToLoad(message: string, pluginUrl?: string) {
     // Show an alert with the error
     document.dispatchEvent(
-      new CustomEvent('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>('show-alert', {
         detail: {
           message: `Plugin install error: ${message} from ${pluginUrl}`,
         },
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
index 9066911..36f518b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -350,27 +350,27 @@
     // The outputArray is used to store all of the matches found for all
     // patterns.
     const outputArray: CommentLinkItem[] = [];
-    for (const p in config) {
+    for (const [configName, linkInfo] of Object.entries(config)) {
       // TODO(TS): it seems, the following line can be rewritten as:
       // if(enabled === false || enabled === 0 || enabled === '')
       // Should be double-checked before update
       // eslint-disable-next-line eqeqeq
-      if (config[p].enabled != null && config[p].enabled == false) {
+      if (linkInfo.enabled != null && linkInfo.enabled == false) {
         continue;
       }
       // PolyGerrit doesn't use hash-based navigation like the GWT UI.
       // Account for this.
-      const html = config[p].html;
-      const link = config[p].link;
+      const html = linkInfo.html;
+      const link = linkInfo.link;
       if (html) {
-        config[p].html = html.replace(/<a href="#\//g, '<a href="/');
+        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
       } else if (link) {
         if (link[0] === '#') {
-          config[p].link = link.substr(1);
+          linkInfo.link = link.substr(1);
         }
       }
 
-      const pattern = new RegExp(config[p].match, 'g');
+      const pattern = new RegExp(linkInfo.match, 'g');
 
       let match;
       let textToCheck = text;
@@ -382,10 +382,10 @@
           pattern,
           // Either html or link has a value. Otherwise an exception is thrown
           // in the code below.
-          (config[p].html || config[p].link)!
+          (linkInfo.html || linkInfo.link)!
         );
 
-        if (config[p].html) {
+        if (linkInfo.html) {
           let i;
           // Skip portion of replacement string that is equal to original to
           // allow overlapping patterns.
@@ -402,7 +402,7 @@
             match[0].length - i,
             outputArray
           );
-        } else if (config[p].link) {
+        } else if (linkInfo.link) {
           this.addLink(
             match[0],
             result,
@@ -413,7 +413,7 @@
         } else {
           throw Error(
             'linkconfig entry ' +
-              p +
+              configName +
               ' doesn’t contain a link or html attribute.'
           );
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 355691f..84d84b1 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -259,27 +259,6 @@
 type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
 
 export function _testOnlyResetGrRestApiSharedObjects() {
-  // TODO(TS): The commented code below didn't do anything.
-  // It is impossible to reject an existing promise. Should be rewritten in a
-  // different way
-  // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData();
-  // for (const key in fetchPromisesCacheData) {
-  //   if (hasOwnProperty(fetchPromisesCacheData, key)) {
-  //     // reject already fulfilled promise does nothing
-  //     fetchPromisesCacheData[key]!.reject();
-  //   }
-  // }
-  //
-  // for (const key in pendingRequest) {
-  //   if (!hasOwnProperty(pendingRequest, key)) {
-  //     continue;
-  //   }
-  //   for (const req of pendingRequest[key]) {
-  //     // reject already fulfilled promise does nothing
-  //     req.reject();
-  //   }
-  // }
-
   siteBasedCache = new SiteBasedCache();
   fetchPromisesCache = new FetchPromisesCache();
   pendingRequest = {};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index d6b6645..710445c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -23,7 +23,6 @@
   AuthRequestInit,
   AuthService,
 } from '../../../../services/gr-auth/gr-auth';
-import {hasOwnProperty} from '../../../../utils/common-util';
 import {
   AccountDetailInfo,
   EmailInfo,
@@ -379,11 +378,7 @@
     }
 
     const params: Array<string | number | boolean> = [];
-    for (const p in fetchParams) {
-      if (!hasOwnProperty(fetchParams, p)) {
-        continue;
-      }
-      const paramValue = fetchParams[p];
+    for (const [p, paramValue] of Object.entries(fetchParams)) {
       // TODO(TS): Replace == null with === and check for null and undefined
       // eslint-disable-next-line eqeqeq
       if (paramValue == null) {
@@ -482,11 +477,8 @@
       if (!options.headers) {
         options.headers = new Headers();
       }
-      for (const header in req.headers) {
-        if (!hasOwnProperty(req.headers, header)) {
-          continue;
-        }
-        options.headers.set(header, req.headers[header]);
+      for (const [name, value] of Object.entries(req.headers)) {
+        options.headers.set(name, value);
       }
     }
     const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index 809b78b..1a1062c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -25,7 +25,6 @@
   ReviewerUpdateInfo,
   Timestamp,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {accountKey} from '../../../utils/account-util';
 import {
   FormattedReviewerUpdateInfo,
@@ -122,12 +121,10 @@
    */
   private _completeBatch(batch: ParserBatch) {
     const items = [];
-    for (const accountId in this._updateItems) {
-      if (!hasOwnProperty(this._updateItems, accountId)) continue;
-      const updateItem = this._updateItems[accountId];
-      if (this._lastState[accountId] !== updateItem.state) {
-        this._lastState[accountId] = updateItem.state;
-        items.push(updateItem);
+    for (const [accountId, item] of Object.entries(this._updateItems ?? {})) {
+      if (this._lastState[accountId] !== item.state) {
+        this._lastState[accountId] = item.state;
+        items.push(item);
       }
     }
     if (items.length) {
@@ -233,15 +230,10 @@
     const reviewerUpdates = (this.result
       .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
     for (const update of reviewerUpdates) {
-      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const groupedReviewers = this._groupUpdatesByMessage(update.updates);
       const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
-      for (const message in grouppedReviewers) {
-        if (hasOwnProperty(grouppedReviewers, message)) {
-          newUpdates.push({
-            message,
-            reviewers: grouppedReviewers[message],
-          });
-        }
+      for (const [message, reviewers] of Object.entries(groupedReviewers)) {
+        newUpdates.push({message, reviewers});
       }
       ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
     }
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index ad5c676..44b9811 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -15,7 +15,7 @@
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/node_modules_licenses:licenses-map",
-        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/typescript",
         "@tools_npm//@types/node",
     ],
 )
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index bce46c3..9546d0b 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -24,7 +24,7 @@
   LinkIcon,
   RunStatus,
 } from '../../api/checks';
-import {map} from 'rxjs/operators';
+import {distinctUntilChanged, map} from 'rxjs/operators';
 
 // This is a convenience type for working with results, because when working
 // with a bunch of results you will typically also want to know about the run
@@ -48,6 +48,11 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
+export const aPluginHasRegistered = checksState$.pipe(
+  map(state => Object.keys(state).length > 0),
+  distinctUntilChanged()
+);
+
 export const allRuns$ = checksState$.pipe(
   map(state => {
     return Object.values(state).reduce(
@@ -108,6 +113,13 @@
       ],
       tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
     },
+    {
+      category: Category.ERROR,
+      summary: 'Running the mighty test has failed by crashing.',
+      links: [
+        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+      ],
+    },
   ],
   status: RunStatus.COMPLETED,
 };
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index de64e7c..86b9b47 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -15,16 +15,47 @@
  * limitations under the License.
  */
 import {Category, CheckRun, RunStatus} from '../../api/checks';
+import {assertNever} from '../../utils/common-util';
 
 export function worstCategory(run: CheckRun) {
-  const results = run.results ?? [];
-  if (results.some(r => r.category === Category.ERROR)) return Category.ERROR;
-  if (results.some(r => r.category === Category.WARNING))
-    return Category.WARNING;
-  if (results.some(r => r.category === Category.INFO)) return Category.INFO;
+  if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
+  if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
+  if (hasResultsOf(run, Category.INFO)) return Category.INFO;
   return undefined;
 }
 
+export function iconForCategory(category: Category) {
+  switch (category) {
+    case Category.ERROR:
+      return 'error';
+    case Category.INFO:
+      return 'info-outline';
+    case Category.WARNING:
+      return 'warning';
+    default:
+      assertNever(category, `Unsupported category: ${category}`);
+  }
+}
+
+export function iconForRun(run: CheckRun) {
+  const category = worstCategory(run);
+  return category ? iconForCategory(category) : iconForStatus(run.status);
+}
+
+export function iconForStatus(status: RunStatus) {
+  switch (status) {
+    // Note that this is only for COMPLETED without results!
+    case RunStatus.COMPLETED:
+      return 'check-circle-outline';
+    case RunStatus.RUNNABLE:
+      return 'placeholder';
+    case RunStatus.RUNNING:
+      return 'timelapse';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
 export function hasCompleted(run: CheckRun) {
   return run.status === RunStatus.COMPLETED;
 }
@@ -41,6 +72,18 @@
   return run.status === RunStatus.COMPLETED && (run.results ?? []).length === 0;
 }
 
+export function hasCompletedWith(run: CheckRun, category: Category) {
+  return hasCompleted(run) && hasResultsOf(run, category);
+}
+
+export function hasResultsOf(run: CheckRun, category: Category) {
+  return getResultsOf(run, category).length > 0;
+}
+
+export function getResultsOf(run: CheckRun, category: Category) {
+  return (run.results ?? []).filter(r => r.category === category);
+}
+
 export function compareByWorstCategory(a: CheckRun, b: CheckRun) {
   return level(worstCategory(b)) - level(worstCategory(a));
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index c43a8fd..bf62d0d0 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,8 +25,10 @@
  */
 export enum KnownExperimentId {
   NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+  // Note that this flag is not supposed to be used by Gerrit itself, but can
+  // be used by plugins. The new Checks UI will show up, if a plugin registers
+  // with the new Checks plugin API.
   CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
   NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
   PORTING_COMMENTS = 'UiFeature__porting_comments',
-  AUTO_RELOAD_DASHBOARD = 'UiFeature__auto_reload_dashboard',
 }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 7636734..c3b0681 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -74,9 +74,12 @@
     --warning-background: var(--orange-50);
     --info-foreground: var(--blue-700);
     --info-background: var(--blue-50);
+    --selected-foreground: var(--blue-700);
+    --selected-background: var(--blue-50);
     --info-deemphasized-foreground: var(--gray-300);
     --info-deemphasized-background: var(--gray-50);
     --success-foreground: var(--green-700);
+    --success-background: var(--green-50);
     --gray-foreground: var(--gray-700);
     --gray-background: var(--gray-100);
     --tag-background: var(--cyan-100);
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index beba88e..6109653 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -43,7 +43,6 @@
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
-import {hasOwnProperty} from '../utils/common-util';
 
 declare global {
   interface Window {
@@ -132,12 +131,9 @@
   // This method is inspired by web-component-tester method
   const proto = document.createElement(tagName).constructor
     .prototype as HTMLElementTagNameMap[T];
-  let key: keyof HTMLElementTagNameMap[T];
   const stubs: SinonSpy[] = [];
-  for (key in implementation) {
-    if (hasOwnProperty(implementation, key)) {
-      stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
-    }
+  for (const [key, value] of Object.entries(implementation)) {
+    stubs.push(sinon.stub(proto, key).callsFake(value));
   }
   registerTestCleanup(() => {
     stubs.forEach(stub => {
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index e9b2900..163c760 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -180,6 +180,10 @@
 
 export interface ShowAlertEventDetail {
   message: string;
+  dismissOnNavigation?: boolean;
+  showDismiss?: boolean;
+  action?: string;
+  callback?: () => void;
 }
 
 export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 99c864e..bb9f328 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -35,3 +35,17 @@
 export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
   return accounts?.filter(a => !isServiceUser(a)) || [];
 }
+
+export function hasSameAvatar(account?: AccountInfo, other?: AccountInfo) {
+  return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
+}
+
+export function uniqueDefinedAvatar(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index === accountArray.findIndex(other => hasSameAvatar(account, other))
+  );
+}
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 094aa3b..8839be1 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -201,6 +201,15 @@
   return change.revisions[change.current_revision];
 }
 
+export function getRevisionKey(
+  change: ChangeInfo | ParsedChangeInfo,
+  patchNum: PatchSetNum
+) {
+  return Object.keys(change.revisions ?? []).find(
+    rev => change?.revisions?.[rev]._number === patchNum
+  );
+}
+
 export function changeStatusString(change: ChangeInfo) {
   return changeStatuses(change).join(', ');
 }
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 9c34b60..d7e7595 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -173,6 +173,10 @@
   return thread && len ? thread.comments[len - 1] : undefined;
 }
 
+export function getFirstComment(thread?: CommentThread): UIComment | undefined {
+  return thread?.comments?.[0];
+}
+
 export function isUnresolved(thread?: CommentThread): boolean {
   return !isResolved(thread);
 }
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 5b332ea..ad76b79 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -47,6 +47,26 @@
 }
 
 /**
+ * Throws an error with the provided error message if the condition is false.
+ */
+export function check(
+  condition: boolean,
+  errorMessage: string
+): asserts condition {
+  if (!condition) throw new Error(errorMessage);
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function checkProperty(
+  condition: boolean,
+  propertyName: string
+): asserts condition {
+  check(condition, `missing required property '${propertyName}'`);
+}
+
+/**
  * Returns true, if both sets contain the same members.
  */
 export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index c9a7d6b..aa83173 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -153,6 +153,12 @@
   return [...results];
 }
 
+export function windowLocationReload() {
+  const e = new Error();
+  console.info(`Calling window.location.realod(): ${e.stack}`);
+  window.location.reload();
+}
+
 /**
  * Retrieves the dom path of the current event.
  *
@@ -244,3 +250,11 @@
   }
   return root.activeElement as HTMLElement;
 }
+
+// Whether the browser is Safari. Used for polyfilling unique browser behavior.
+export function isSafari() {
+  return (
+    /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
+    (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
+  );
+}
diff --git a/proto/BUILD b/proto/BUILD
index 57be265..7aa761d 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -4,6 +4,7 @@
 proto_library(
     name = "cache_proto",
     srcs = ["cache.proto"],
+    deps = [":entities_proto"],
 )
 
 java_proto_library(
diff --git a/proto/cache.proto b/proto/cache.proto
index f3db71f..4fd037d 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -18,7 +18,9 @@
 
 option java_package = "com.google.gerrit.server.cache.proto";
 
-// Serialized form of com.google.gerrit.server.change.CHangeKindCacheImpl.Key.
+import "proto/entities.proto";
+
+// Serialized form of com.google.gerrit.server.change.ChangeKindCacheImpl.Key.
 // Next ID: 4
 message ChangeKindKeyProto {
   bytes prior = 1;
@@ -140,11 +142,9 @@
 
   repeated string hashtag = 5;
 
-  // Raw PatchSet proto as produced by PatchSetProtoConverter.
-  repeated bytes patch_set = 6;
+  repeated devtools.gerritcodereview.PatchSet patch_set = 6;
 
-  // Raw PatchSetApproval proto as produced by PatchSetApprovalProtoConverter.
-  repeated bytes approval = 7;
+  repeated devtools.gerritcodereview.PatchSetApproval approval = 7;
 
   // Next ID: 4
   message ReviewerSetEntryProto {
@@ -184,8 +184,7 @@
   // com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord.
   repeated string submit_record = 14;
 
-  // Raw ChangeMessage proto as produced by ChangeMessageProtoConverter.
-  repeated bytes change_message = 15;
+  repeated devtools.gerritcodereview.ChangeMessage change_message = 15;
 
   // JSON produced from com.google.gerrit.entities.Comment.
   repeated string published_comment = 16;
@@ -511,7 +510,7 @@
 }
 
 // Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
-// Next ID: 6
+// Next ID: 7
 message CommentContextKeyProto {
   string project = 1;
   string change_id = 2;
@@ -520,6 +519,8 @@
 
   // hashed with the murmur3_128 hash function
   string path_hash = 5;
+
+  int32 context_padding = 6;
 }
 
 // Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index 051cfe8..b88ec24 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -10,7 +10,7 @@
     compiler = "//tools/node_tools:tsc_wrapped-bin",
     tsconfig = "tsconfig.json",
     deps = [
-        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/typescript",
         "@tools_npm//@types/node",
     ],
 )
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index ba93c94..9acbd07 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^3.1.0",
-    "@bazel/typescript": "^3.1.0",
+    "@bazel/rollup": "^3.2.0",
+    "@bazel/typescript": "^3.2.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 28d787e..45a0c89 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.1.0.tgz#36346f052b2ce3c1e31e5ebb05ed80464548eb00"
-  integrity sha512-lmgPhlR1VsJRsSE83Jlv+WT26Lso0/0FqXknlVuOmvCWFwSUKlriws729fqJZsvV5O2muAgJKuQl/zk+gqGCug==
+"@bazel/rollup@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.0.tgz#4241a5767e12e57b01279a539af2537c2d01924a"
+  integrity sha512-Wkw6L+hor/+FzpDswri7IlWAbKyShnUZRx59fG06+qqVhpNaS3V3lnZqVytMlLLT4oSP8YSIzoXC5GkXgLI2/Q==
 
-"@bazel/typescript@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.1.0.tgz#a07999ad7956b8c624604a521e653570bba32025"
-  integrity sha512-sEWuvkUGIDeRhjLENHtJyop7wu4UqKN8h/nSgUwc5gkpWXQiT2wGH5jKVxBqODOBHB+IInEMtAjyRmCT+HbSHA==
+"@bazel/typescript@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.0.tgz#299bd173fe04f98407ab9be4f654662c1c28470e"
+  integrity sha512-RKdy9ThbcUAqZR3AJK7AR/nxbJqdHi7pPayIGUSMIpxVkeTxVRQpf1aGe2H02HdZ9fR/uk1xXhO/Ff9TLvTgHQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3eb1e4b..e8f12c8 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,9 +1,8 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
 
-GUAVA_VERSION = "29.0-jre"
+GUAVA_VERSION = "30.1-jre"
 
-GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
+GUAVA_BIN_SHA1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
@@ -142,34 +141,24 @@
         sha1 = GUAVA_BIN_SHA1,
     )
 
-    GUICE_VERS = "4.2.3"
+    GUICE_VERS = "5.0.0-BETA-1"
 
-    GUICE_LIBRARY_SHA256 = "5168f5e7383f978c1b4154ac777b78edd8ac214bb9f9afdb92921c8d156483d3"
-
-    http_file(
-        name = "guice-library-no-aop",
-        canonical_id = "guice-library-no-aop-" + GUICE_VERS + ".jar-" + GUICE_LIBRARY_SHA256,
-        downloaded_file_path = "guice-library-no-aop.jar",
-        sha256 = GUICE_LIBRARY_SHA256,
-        urls = [
-            "https://repo1.maven.org/maven2/com/google/inject/guice/" +
-            GUICE_VERS +
-            "/guice-" +
-            GUICE_VERS +
-            "-no_aop.jar",
-        ],
+    maven_jar(
+        name = "guice-library",
+        artifact = "com.google.inject:guice:" + GUICE_VERS,
+        sha1 = "c5572be8a8b75ea50e0fdf54fa1f75a3141ab936",
     )
 
     maven_jar(
         name = "guice-assistedinject",
         artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-        sha1 = "acbfddc556ee9496293ed1df250cc378f331d854",
+        sha1 = "4d06eba0e08151b52d9e25a14e4f01eedf998bc3",
     )
 
     maven_jar(
         name = "guice-servlet",
         artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-        sha1 = "8d6e7e35eac4fb5e7df19c55b3bc23fa51b10a11",
+        sha1 = "373b9a4f1b6683d9a991410660d2c9adb9f06737",
     )
 
     # Keep this version of Soy synchronized with the version used in Gitiles.
diff --git a/yarn.lock b/yarn.lock
index 170b18a..a424d79 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,20 +485,20 @@
     lodash "^4.17.11"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.1.0.tgz#36346f052b2ce3c1e31e5ebb05ed80464548eb00"
-  integrity sha512-lmgPhlR1VsJRsSE83Jlv+WT26Lso0/0FqXknlVuOmvCWFwSUKlriws729fqJZsvV5O2muAgJKuQl/zk+gqGCug==
+"@bazel/rollup@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.0.tgz#4241a5767e12e57b01279a539af2537c2d01924a"
+  integrity sha512-Wkw6L+hor/+FzpDswri7IlWAbKyShnUZRx59fG06+qqVhpNaS3V3lnZqVytMlLLT4oSP8YSIzoXC5GkXgLI2/Q==
 
-"@bazel/terser@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.1.0.tgz#5801e83d4ac648fb1a8824a77a1a1f32c3af0c1e"
-  integrity sha512-8oXZwy5G5dbr4zltBzLjfPw4ZARDEysB2E25dCqAo64XJ26ptS+D3/UnE3uZU9KuM/3ka1U+YIpit+f9SqCgTA==
+"@bazel/terser@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.2.0.tgz#e53ad32733a0b231323b9eb55ebc2a3c65b10223"
+  integrity sha512-/yq4gST3t1mETkP6NjC05yEyIIL//4mbfLI56hE3CC/mm/xJ6UeooFVpUdlJREQEDRAdNWoiMesQ1ZtgpNPzFg==
 
-"@bazel/typescript@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.1.0.tgz#a07999ad7956b8c624604a521e653570bba32025"
-  integrity sha512-sEWuvkUGIDeRhjLENHtJyop7wu4UqKN8h/nSgUwc5gkpWXQiT2wGH5jKVxBqODOBHB+IInEMtAjyRmCT+HbSHA==
+"@bazel/typescript@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.0.tgz#299bd173fe04f98407ab9be4f654662c1c28470e"
+  integrity sha512-RKdy9ThbcUAqZR3AJK7AR/nxbJqdHi7pPayIGUSMIpxVkeTxVRQpf1aGe2H02HdZ9fR/uk1xXhO/Ff9TLvTgHQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"