Merge "Add request latency (duration) to the httpd_log"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index d3f5d77..13e3a53 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -5,6 +5,12 @@
 to those groups.  Access rights cannot be granted to individual
 users.
 
+To view/edit the access controls for a specific project, first
+navigate to the projects page: for example,
+https://gerrit-review.googlesource.com/admin/repos/ . Then click on
+the individual project, and then click Access. This will bring you
+to a url that looks like
+https://gerrit-review.googlesource.com/admin/repos/gerrit,access
 
 [[system_groups]]
 == System Groups
diff --git a/Documentation/config-cla.txt b/Documentation/config-cla.txt
index 2234808..2c7b194 100644
--- a/Documentation/config-cla.txt
+++ b/Documentation/config-cla.txt
@@ -25,13 +25,15 @@
 ----
 
 Contributor agreements are defined as contributor-agreement sections in
-`project.config`:
+`project.config` of `All-Projects`:
 ----
   [contributor-agreement "Individual"]
     description = If you are going to be contributing code on your own, this is the one you want. You can sign this one online.
     agreementUrl = static/cla_individual.html
     autoVerify = group CLA Accepted - Individual
     accepted = group CLA Accepted - Individual
+    matchProjects = ^/.*$
+    excludeProjects = ^/not/my/project/
 ----
 
 Each `contributor-agreement` section within the `project.config` file must
@@ -75,6 +77,16 @@
 contributor agreement has been accepted. The groups' UUID must also
 appear in the `groups` file.
 
+[[contributor-agreement.name.matchProjects]]contributor-agreement.<name>.matchProjects::
++
+List of project regular expressions identifying projects where the
+agreement is required. Defaults to every project when omitted.
+
+[[contributor-agreement.name.excludeProjects]]contributor-agreement.<name>.excludeProjects::
++
+List of project regular expressions identifying projects where the
+agreement does not apply. Defaults to empty. i.e. no projects excluded.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 0ecc820..a68c3ac 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -258,8 +258,10 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
-To run the tests against NoteDb backend with write
-to NoteDb, but not read from it:
+The tests run with NoteDb fully enabled by default.
+
+To run the tests against NoteDb backend with write to NoteDb, but not read from
+it:
 
 ----
   bazel test --test_env=GERRIT_NOTEDB=WRITE //...
@@ -277,10 +279,10 @@
   bazel test --test_env=GERRIT_NOTEDB=PRIMARY //...
 ----
 
-Primary storage NoteDb and ReviewDb disabled:
+NoteDb entirely disabled:
 
 ----
-  bazel test --test_env=GERRIT_NOTEDB=ON //...
+  bazel test --test_env=GERRIT_NOTEDB=OFF //...
 ----
 
 To run only tests that do not use SSH:
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index 8bedd08..5ec6ae8 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -177,9 +177,9 @@
 plugin manages a project, it intercepts the creation and creates a Bazel test
 run configuration instead, which can be used just like the standard ones.
 
-TIP: If you would like to execute a test in NoteDb mode, add
-`--test_env=GERRIT_NOTEDB=READ_WRITE` to the *Bazel flags* of your run
-configuration.
+TIP: Tests run with NoteDb enabled by default. If you would like to execute a
+test with NoteDb turned off, add `--test_env=GERRIT_NOTEDB=OFF` to the *Bazel
+flags* of your run configuration.
 
 [[remote-debug]]
 === Debugging a remote Gerrit server
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 37d6d01..db6f883 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -177,6 +177,10 @@
 * `plugin/latency`: Latency for plugin invocation.
 * `plugin/error_count`: Number of plugin errors.
 
+=== Group
+
+* `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+
 === Replication Plugin
 
 * `plugins/replication/replication_latency`: Time spent pushing to remote
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index a340163..b1c9a88 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5769,8 +5769,12 @@
 Number of inserted lines.
 |`deletions`          ||
 Number of deleted lines.
+|`total_comment_count`  |optional|
+Total number of inline comments across all patch sets. Not set if the current
+change index doesn't have the data.
 |`unresolved_comment_count`  |optional|
-Number of unresolved comments. Not set if the current change index doesn't have the data.
+Number of unresolved inline comment threads across all patch sets. Not set if
+the current change index doesn't have the data.
 |`_number`            ||The legacy numeric ID of the change.
 |`owner`              ||
 The owner of the change as an link:rest-api-accounts.html#account-info[
@@ -5969,7 +5973,8 @@
 Callers can find out if there were conflicts by checking the
 `contains_git_conflicts` field in the link:#cherry-pick-change-info[
 CherryPickChangeInfo] that is returned by the cherry-pick REST
-endpoints.
+endpoints. If there are conflicts the cherry-pick change is marked as
+work-in-progress.
 |===========================
 
 [[comment-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 58e67e8..3214761 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3084,6 +3084,25 @@
 AutoCloseableChangesCheckResult] entity.
 |==================================================
 
+[[commentlink-info]]
+=== CommentLinkInfo
+The `CommentLinkInfo` entity describes a
+link:config-gerrit.html#commentlink[commentlink].
+
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name |        |Description
+|`match`    |        |A JavaScript regular expression to match
+positions to be replaced with a hyperlink, as documented in
+link#config-gerrit.html#commentlink.name.match[commentlink.name.match].
+|`link`     |        |The URL to direct the user to whenever the
+regular expression is matched, as documented in
+link#config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`enabled`  |optional|Whether the commentlink is enabled, as documented
+in link#config-gerrit.html#commentlink.name.enabled[
+commentlink.name.enabled]. If not set the commentlink is enabled.
+|==================================================
+
 [[config-info]]
 === ConfigInfo
 The `ConfigInfo` entity contains information about the effective project
@@ -3150,10 +3169,8 @@
 Not set if the project state is `ACTIVE`.
 |`commentlinks`              ||
 Map with the comment link configurations of the project. The name of
-the comment link configuration is mapped to the comment link
-configuration, which has the same format as the
-link:config-gerrit.html#_a_id_commentlink_a_section_commentlink[
-commentlink section] of `gerrit.config`.
+the comment link configuration is mapped to a link:#commentlink-info[
+CommentlinkInfo] entity.
 |`theme`                                   |optional|
 The theme that is configured for the project as a link:#theme-info[
 ThemeInfo] entity.
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 99ce645..75f2b3f 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -307,7 +307,7 @@
 The type of a file modification is indicated by the character in front
 of the file name:
 
-- 'no character' (Modified):
+- `M` or 'no character' (Modified):
 +
 The file existed before this change and is modified.
 
@@ -327,6 +327,10 @@
 +
 The file is new and is copied from an existing file.
 
+- `U` (Unchanged):
++
+The file is unchanged and has the same content.
+
 image::images/user-review-ui-change-screen-file-list-modification-type.png[width=800, link="images/user-review-ui-change-screen-file-list-modification-type.png"]
 
 [[rename-or-copy]]
diff --git a/WORKSPACE b/WORKSPACE
index 62331cd..37080b7 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -15,9 +15,9 @@
 
 http_archive(
     name = "io_bazel_rules_closure",
-    sha256 = "4dd84dd2bdd6c9f56cb5a475d504ea31d199c34309e202e9379501d01c3067e5",
-    strip_prefix = "rules_closure-3103a773820b59b76345f94c231cb213e0d404e2",
-    urls = ["https://github.com/bazelbuild/rules_closure/archive/3103a773820b59b76345f94c231cb213e0d404e2.tar.gz"],
+    sha256 = "5b4b610ea4892116b6126fa689218535629305590c43fbd68034d831953a9989",
+    strip_prefix = "rules_closure-409a86250c457ca15cafde35eb169e4c2601570e",
+    urls = ["https://github.com/bazelbuild/rules_closure/archive/409a86250c457ca15cafde35eb169e4c2601570e.zip"],
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -48,8 +48,8 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "97cf62bdef33519412167fd1e4b0810a318a7c234f5f8dc4f53e2da86241c492",
-    urls = ["https://github.com/bazelbuild/rules_go/releases/download/0.15.3/rules_go-0.15.3.tar.gz"],
+    sha256 = "ee5fe78fe417c685ecb77a0a725dc9f6040ae5beb44a0ba4ddb55453aad23a8a",
+    url = "https://github.com/bazelbuild/rules_go/releases/download/0.16.0/rules_go-0.16.0.tar.gz",
 )
 
 load("@io_bazel_rules_go//go:def.bzl", "go_register_toolchains", "go_rules_dependencies")
@@ -108,24 +108,24 @@
     sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
 )
 
-GUICE_VERS = "4.2.1"
+GUICE_VERS = "4.2.2"
 
 maven_jar(
     name = "guice-library",
     artifact = "com.google.inject:guice:" + GUICE_VERS,
-    sha1 = "f77dfd89318fe3ff293bafceaa75fbf66e4e4b10",
+    sha1 = "6dacbe18e5eaa7f6c9c36db33b42e7985e94ce77",
 )
 
 maven_jar(
     name = "guice-assistedinject",
     artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-    sha1 = "d327e4aee7c96f08cd657c17da231a1f4a8999ac",
+    sha1 = "c33fb10080d58446f752b4fcfff8a5fabb80a449",
 )
 
 maven_jar(
     name = "guice-servlet",
     artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-    sha1 = "3927e462f923b0c672fdb045c5645bca4beab5c0",
+    sha1 = "0d0054bdd812224078357a9b11409e43d182a046",
 )
 
 maven_jar(
@@ -586,36 +586,36 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "6.2.1"
+OW2_VERS = "7.0"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "c01b6798f81b0fc2c5faa70cbe468c275d4b50c7",
+    sha1 = "d74d4ba0dee443f68fb2dcb7fcdb945a2cd89912",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "e8b876c5ccf226cae2f44ed2c436ad3407d0ec1d",
+    sha1 = "4b310d20d6f1c6b7197a75f1b5d69f169bc8ac1f",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "eaf31376d741a3e2017248a4c759209fe25c77d3",
+    sha1 = "478006d07b7c561ae3a92ddc1829bca81ae0cdd1",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "332b022092ecec53cdb6272dc436884b2d940615",
+    sha1 = "29bc62dcb85573af6e62e5b2d735ef65966c4180",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "400d664d7c92a659d988c00cb65150d1b30cf339",
+    sha1 = "18d4d07010c24405129a6dbb0e92057f8779fb9d",
 )
 
 AUTO_VALUE_VERSION = "1.6.2"
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 3ad4ae9..d168919 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -253,6 +253,7 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected PatchSetUtil psUtil;
   @Inject protected ProjectCache projectCache;
+  @Inject protected ProjectConfig.Factory projectConfigFactory;
   @Inject protected ProjectResetter.Builder.Factory projectResetter;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected PushOneCommit.Factory pushFactory;
@@ -269,6 +270,7 @@
   protected Project.NameKey project;
   protected RestSession adminRestSession;
   protected RestSession userRestSession;
+  protected RestSession anonymousRestSession;
   protected ReviewDb db;
   protected SshSession adminSshSession;
   protected SshSession userSshSession;
@@ -445,6 +447,7 @@
 
     adminRestSession = new RestSession(server, admin);
     userRestSession = new RestSession(server, user);
+    anonymousRestSession = new RestSession(server, null);
 
     initSsh();
 
@@ -998,7 +1001,7 @@
 
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       config.getProject().setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value);
       config.commit(md);
       projectCache.evict(config.getProject());
@@ -1007,7 +1010,7 @@
 
   protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       config.getProject().setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value);
       config.commit(md);
       projectCache.evict(config.getProject());
@@ -1016,7 +1019,7 @@
 
   protected void setRequireChangeId(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       config.getProject().setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value);
       config.commit(md);
       projectCache.evict(config.getProject());
@@ -1078,7 +1081,7 @@
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
       PermissionRule rule = Util.newRule(config, groupUUID);
@@ -1102,7 +1105,7 @@
     String permission = Permission.LABEL + label;
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
       p.setExclusiveGroup(exclusive);
@@ -1120,7 +1123,7 @@
       throws IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Remove %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
       p.clearRules();
@@ -1245,14 +1248,14 @@
   protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
       throws Exception {
     ContributorAgreement ca;
+    String g = createGroup(autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group");
+    GroupApi groupApi = gApi.groups().id(g);
+    groupApi.description("CLA test group");
+    InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
+    GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
+    PermissionRule rule = new PermissionRule(groupRef);
+    rule.setAction(PermissionRule.Action.ALLOW);
     if (autoVerify) {
-      String g = createGroup("cla-test-group");
-      GroupApi groupApi = gApi.groups().id(g);
-      groupApi.description("CLA test group");
-      InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
-      GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
-      PermissionRule rule = new PermissionRule(groupRef);
-      rule.setAction(PermissionRule.Action.ALLOW);
       ca = new ContributorAgreement("cla-test");
       ca.setAutoVerify(groupRef);
       ca.setAccepted(ImmutableList.of(rule));
@@ -1261,6 +1264,8 @@
     }
     ca.setDescription("description");
     ca.setAgreementUrl("agreement-url");
+    ca.setAccepted(ImmutableList.of(rule));
+    ca.setExcludeProjectsRegexes(ImmutableList.of("ExcludedProject"));
 
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
       u.getConfig().replace(ca);
@@ -1668,7 +1673,7 @@
 
     private ProjectConfigUpdate(Project.NameKey projectName) throws Exception {
       metaDataUpdate = metaDataUpdateFactory.create(projectName);
-      projectConfig = ProjectConfig.read(metaDataUpdate);
+      projectConfig = projectConfigFactory.read(metaDataUpdate);
     }
 
     public ProjectConfig getConfig() {
diff --git a/java/com/google/gerrit/acceptance/HttpSession.java b/java/com/google/gerrit/acceptance/HttpSession.java
index fe446f4..fdd1fce 100644
--- a/java/com/google/gerrit/acceptance/HttpSession.java
+++ b/java/com/google/gerrit/acceptance/HttpSession.java
@@ -19,8 +19,10 @@
 import java.io.IOException;
 import java.net.URI;
 import org.apache.http.HttpHost;
+import org.apache.http.client.HttpClient;
 import org.apache.http.client.fluent.Executor;
 import org.apache.http.client.fluent.Request;
+import org.apache.http.impl.client.HttpClientBuilder;
 
 public class HttpSession {
   protected TestAccount account;
@@ -30,7 +32,8 @@
   public HttpSession(GerritServer server, @Nullable TestAccount account) {
     this.url = CharMatcher.is('/').trimTrailingFrom(server.getUrl());
     URI uri = URI.create(url);
-    this.executor = Executor.newInstance();
+    HttpClient noRedirectClient = HttpClientBuilder.create().disableRedirectHandling().build();
+    this.executor = Executor.newInstance(noRedirectClient);
     this.account = account;
     if (account != null) {
       executor.auth(
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index da08215..e8de5c6 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -21,6 +22,7 @@
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
+import java.net.URI;
 import org.apache.http.HttpStatus;
 
 public class RestResponse extends HttpResponse {
@@ -83,4 +85,9 @@
   public void assertPreconditionFailed() throws Exception {
     assertStatus(HttpStatus.SC_PRECONDITION_FAILED);
   }
+
+  public void assertTemporaryRedirect(String path) throws Exception {
+    assertStatus(HttpStatus.SC_MOVED_TEMPORARILY);
+    assertThat(URI.create(getHeader("Location")).getPath()).isEqualTo(path);
+  }
 }
diff --git a/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
index b43dbfa..a6e8cdd 100644
--- a/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ b/java/com/google/gerrit/common/data/ContributorAgreement.java
@@ -25,6 +25,8 @@
   protected List<PermissionRule> accepted;
   protected GroupReference autoVerify;
   protected String agreementUrl;
+  protected List<String> excludeProjectsRegexes;
+  protected List<String> matchProjectsRegexes;
 
   protected ContributorAgreement() {}
 
@@ -75,6 +77,28 @@
     this.agreementUrl = agreementUrl;
   }
 
+  public List<String> getExcludeProjectsRegexes() {
+    if (excludeProjectsRegexes == null) {
+      excludeProjectsRegexes = new ArrayList<>();
+    }
+    return excludeProjectsRegexes;
+  }
+
+  public void setExcludeProjectsRegexes(List<String> excludeProjectsRegexes) {
+    this.excludeProjectsRegexes = excludeProjectsRegexes;
+  }
+
+  public List<String> getMatchProjectsRegexes() {
+    if (matchProjectsRegexes == null) {
+      matchProjectsRegexes = new ArrayList<>();
+    }
+    return matchProjectsRegexes;
+  }
+
+  public void setMatchProjectsRegexes(List<String> matchProjectsRegexes) {
+    this.matchProjectsRegexes = matchProjectsRegexes;
+  }
+
   @Override
   public int compareTo(ContributorAgreement o) {
     return getName().compareTo(o.getName());
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
index 00d7494..5bf22aa 100644
--- a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -33,23 +33,11 @@
     return MoreObjects.toStringHelper(this)
         .add("project", project)
         .add("changeId", changeId)
-        .add("commit", toString(commit))
+        .add("commit", commit)
         .add("_changeNumber", _changeNumber)
         .add("_revisionNumber", _revisionNumber)
         .add("_currentRevisionNumber", _currentRevisionNumber)
         .add("status", status)
         .toString();
   }
-
-  private static String toString(CommitInfo commit) {
-    return MoreObjects.toStringHelper(commit)
-        .add("commit", commit.commit)
-        .add("parent", commit.parents)
-        .add("author", commit.author)
-        .add("committer", commit.committer)
-        .add("subject", commit.subject)
-        .add("message", commit.message)
-        .add("webLinks", commit.webLinks)
-        .toString();
-  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index 9c64fd0..9e6770b 100644
--- a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import static com.google.common.base.MoreObjects.toStringHelper;
+
 import com.google.common.collect.ComparisonChain;
 import java.util.Objects;
 
@@ -47,4 +49,14 @@
   public int hashCode() {
     return Objects.hash(identity, emailAddress, trusted, canDelete);
   }
+
+  @Override
+  public String toString() {
+    return toStringHelper(this)
+        .add("identity", identity)
+        .add("emailAddress", emailAddress)
+        .add("trusted", trusted)
+        .add("canDelete", canDelete)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index c95dcc3..9a739ef 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -23,6 +23,8 @@
 import java.util.Map;
 
 public class ChangeInfo {
+  // ActionJson#copy(List, ChangeInfo) must be adapted if new fields are added that are not
+  // protected by any ListChangesOption.
   public String id;
   public String project;
   public String branch;
@@ -44,6 +46,7 @@
   public Boolean submittable;
   public Integer insertions;
   public Integer deletions;
+  public Integer totalCommentCount;
   public Integer unresolvedCommentCount;
   public Boolean isPrivate;
   public Boolean workInProgress;
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
index 613c7a4..1fd8755 100644
--- a/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -16,6 +16,8 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
 import java.util.List;
 import java.util.Objects;
 
@@ -50,19 +52,18 @@
 
   @Override
   public String toString() {
-    // Using something like the raw commit format might be nice, but we can't depend on JGit here.
-    StringBuilder sb = new StringBuilder().append(getClass().getSimpleName()).append('{');
-    sb.append(commit);
+    ToStringHelper helper = MoreObjects.toStringHelper(this).addValue(commit);
     if (parents != null) {
-      sb.append(", parents=").append(parents.stream().map(p -> p.commit).collect(joining(", ")));
+      helper.add("parents", parents.stream().map(p -> p.commit).collect(joining(", ")));
     }
-    sb.append(", author=").append(author);
-    sb.append(", committer=").append(committer);
-    sb.append(", subject=").append(subject);
-    sb.append(", message=").append(message);
+    helper
+        .add("author", author)
+        .add("committer", committer)
+        .add("subject", subject)
+        .add("message", message);
     if (webLinks != null) {
-      sb.append(", webLinks=").append(webLinks);
+      helper.add("webLinks", webLinks);
     }
-    return sb.append('}').toString();
+    return helper.toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index fc443dd..f262901 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -19,6 +19,8 @@
 import java.util.Map;
 
 public class RevisionInfo {
+  // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
+  // protected by any ListChangesOption.
   public transient boolean isCurrent;
   public ChangeKind kind;
   public int _number;
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
new file mode 100644
index 0000000..164f957
--- /dev/null
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 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.httpd;
+
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Redirects {@code domain.tld/123} to {@code domain.tld/c/project/+/123}. */
+@Singleton
+public class NumericChangeIdRedirectServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final ChangesCollection changesCollection;
+
+  @Inject
+  NumericChangeIdRedirectServlet(ChangesCollection changesCollection) {
+    this.changesCollection = changesCollection;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    String idString = req.getPathInfo();
+    if (idString.endsWith("/")) {
+      idString = idString.substring(0, idString.length() - 1);
+    }
+    Change.Id id;
+    try {
+      id = Change.Id.parse(idString);
+    } catch (IllegalArgumentException e) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+      return;
+    }
+
+    ChangeResource changeResource;
+    try {
+      changeResource = changesCollection.parse(id);
+    } catch (ResourceConflictException | ResourceNotFoundException e) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+      return;
+    } catch (OrmException | PermissionBackendException e) {
+      throw new IOException("Unable to lookup change " + id.id, e);
+    }
+    String path =
+        PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
+    UrlModule.toGerrit(path, req, rsp);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index f337718..183b2bd 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -87,7 +87,7 @@
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
-    serveRegex("^/([1-9][0-9]*)/?$").with(directChangeById());
+    serveRegex("^/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
@@ -110,7 +110,9 @@
     serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
 
-    filter("/Documentation/").through(QueryDocumentationFilter.class);
+    serveRegex("^/Documentation$").with(redirectDocumentation());
+    serveRegex("^/Documentation/$").with(redirectDocumentation());
+    filter("/Documentation/*").through(QueryDocumentationFilter.class);
   }
 
   private Key<HttpServlet> notFound() {
@@ -162,30 +164,6 @@
         });
   }
 
-  private Key<HttpServlet> directChangeById() {
-    return key(
-        new HttpServlet() {
-          private static final long serialVersionUID = 1L;
-
-          @Override
-          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-            try {
-              String idString = req.getPathInfo();
-              if (idString.endsWith("/")) {
-                idString = idString.substring(0, idString.length() - 1);
-              }
-              Change.Id id = Change.Id.parse(idString);
-              // User accessed Gerrit with /1234, so we have no project yet.
-              // TODO(hiesel) Replace with a preflight request to obtain project before we deprecate
-              // the numeric change id.
-              toGerrit(PageLinks.toChange(null, id), req, rsp);
-            } catch (IllegalArgumentException err) {
-              rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            }
-          }
-        });
-  }
-
   private Key<HttpServlet> queryProjectNew() {
     return key(
         new HttpServlet() {
@@ -259,6 +237,19 @@
         });
   }
 
+  private Key<HttpServlet> redirectDocumentation() {
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
+
+          @Override
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+            String path = "/Documentation/index.html";
+            toGerrit(path, req, rsp);
+          }
+        });
+  }
+
   static void toGerrit(String target, HttpServletRequest req, HttpServletResponse rsp)
       throws IOException {
     final StringBuilder url = new StringBuilder();
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 664d881..124ad1c 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -119,6 +119,8 @@
 
   @Override
   protected void configureServlets() {
+    serveRegex("^/Documentation$").with(named(DOC_SERVLET));
+    serveRegex("^/Documentation/$").with(named(DOC_SERVLET));
     serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
     serve("/static/*").with(SiteStaticDirectoryServlet.class);
     install(
diff --git a/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index 24efb86..dd6622a 100644
--- a/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -62,6 +62,7 @@
       ProjectAccessFactory.Factory projectAccessFactory,
       ProjectCache projectCache,
       GroupBackend groupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       MetaDataUpdate.User metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
@@ -77,6 +78,7 @@
       @Nullable @Assisted String message) {
     super(
         groupBackend,
+        projectConfigFactory,
         metaDataUpdateFactory,
         allProjects,
         setParent,
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index 6193e45..4c4959a 100644
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -71,6 +71,7 @@
   private final GroupControl.Factory groupControlFactory;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final AllProjectsName allProjectsName;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   private final Project.NameKey projectName;
   private WebLinks webLinks;
@@ -83,6 +84,7 @@
       GroupControl.Factory groupControlFactory,
       MetaDataUpdate.Server metaDataUpdateFactory,
       AllProjectsName allProjectsName,
+      ProjectConfig.Factory projectConfigFactory,
       WebLinks webLinks,
       @Assisted final Project.NameKey name) {
     this.groupBackend = groupBackend;
@@ -91,6 +93,7 @@
     this.groupControlFactory = groupControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allProjectsName = allProjectsName;
+    this.projectConfigFactory = projectConfigFactory;
     this.webLinks = webLinks;
 
     this.projectName = name;
@@ -108,7 +111,7 @@
     //
     ProjectConfig config;
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      config = ProjectConfig.read(md);
+      config = projectConfigFactory.read(md);
       if (config.updateGroupNames(groupBackend)) {
         md.setMessage("Update group names\n");
         config.commit(md);
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 44c8966..074f6a8 100644
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
@@ -58,6 +59,7 @@
 public abstract class ProjectAccessHandler<T> extends Handler<T> {
 
   protected final GroupBackend groupBackend;
+  protected final ProjectConfig.Factory projectConfigFactory;
   protected final Project.NameKey projectName;
   protected final ObjectId base;
   protected final CurrentUser user;
@@ -77,14 +79,15 @@
 
   protected ProjectAccessHandler(
       GroupBackend groupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       MetaDataUpdate.User metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
       CurrentUser user,
-      Project.NameKey projectName,
+      NameKey projectName,
       ObjectId base,
       List<AccessSection> sectionList,
-      Project.NameKey parentProjectName,
+      NameKey parentProjectName,
       String message,
       ContributorAgreementsChecker contributorAgreements,
       PermissionBackend permissionBackend,
@@ -102,6 +105,7 @@
     this.message = message;
     this.contributorAgreements = contributorAgreements;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.checkIfOwner = checkIfOwner;
   }
 
@@ -113,7 +117,7 @@
     contributorAgreements.check(projectName, user);
 
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      ProjectConfig config = ProjectConfig.read(md, base);
+      ProjectConfig config = projectConfigFactory.read(md, base);
       Set<String> toDelete = scanSectionNames(config);
       PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
 
diff --git a/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 8b2090d..9e8592a 100644
--- a/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -56,6 +57,7 @@
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -80,11 +82,13 @@
   private final ChangesCollection changes;
   private final ChangeInserter.Factory changeInserterFactory;
   private final BatchUpdate.Factory updateFactory;
+  private final boolean allowProjectOwnersToChangeParent;
 
   @Inject
   ReviewProjectAccess(
       PermissionBackend permissionBackend,
       GroupBackend groupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       MetaDataUpdate.User metaDataUpdateFactory,
       ReviewDb db,
       Provider<PostReviewers> reviewersProvider,
@@ -97,6 +101,7 @@
       Sequences seq,
       ContributorAgreementsChecker contributorAgreements,
       Provider<CurrentUser> user,
+      @GerritServerConfig Config config,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
@@ -104,6 +109,7 @@
       @Nullable @Assisted String message) {
     super(
         groupBackend,
+        projectConfigFactory,
         metaDataUpdateFactory,
         allProjects,
         setParent,
@@ -124,6 +130,8 @@
     this.changes = changes;
     this.changeInserterFactory = changeInserterFactory;
     this.updateFactory = updateFactory;
+    this.allowProjectOwnersToChangeParent =
+        config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
   }
 
   // TODO(dborowitz): Hack MetaDataUpdate so it can be created within a BatchUpdate and we can avoid
@@ -178,7 +186,7 @@
       throw new IOException(e);
     }
     addProjectOwnersAsReviewers(rsrc);
-    if (parentProjectUpdate) {
+    if (parentProjectUpdate && !allowProjectOwnersToChangeParent) {
       addAdministratorsAsReviewers(rsrc);
     }
     return changeId;
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 66db468..b208a31 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -76,6 +76,7 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.function.Consumer;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
@@ -129,6 +130,7 @@
       ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
   private static final String SUBMIT_RECORD_STRICT_FIELD =
       ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
+  private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
 
@@ -504,6 +506,7 @@
     }
 
     decodeUnresolvedCommentCount(doc, cd);
+    decodeTotalCommentCount(doc, cd);
     return cd;
   }
 
@@ -633,9 +636,18 @@
 
   private void decodeUnresolvedCommentCount(
       ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(UNRESOLVED_COMMENT_COUNT_FIELD), null);
+    decodeIntField(doc, UNRESOLVED_COMMENT_COUNT_FIELD, cd::setUnresolvedCommentCount);
+  }
+
+  private void decodeTotalCommentCount(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    decodeIntField(doc, TOTAL_COMMENT_COUNT_FIELD, cd::setTotalCommentCount);
+  }
+
+  private static void decodeIntField(
+      ListMultimap<String, IndexableField> doc, String fieldName, Consumer<Integer> consumer) {
+    IndexableField f = Iterables.getFirst(doc.get(fieldName), null);
     if (f != null && f.numericValue() != null) {
-      cd.setUnresolvedCommentCount(f.numericValue().intValue());
+      consumer.accept(f.numericValue().intValue());
     }
   }
 
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index ee0138c..94ce924 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -57,17 +57,20 @@
   private final AllUsersName allUsers;
   private final ProjectCache projectCache;
   private final Provider<MetaDataUpdate.Server> metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   CreateGroupPermissionSyncer(
       AllProjectsName allProjects,
       AllUsersName allUsers,
       ProjectCache projectCache,
-      Provider<MetaDataUpdate.Server> metaDataUpdateFactory) {
+      Provider<MetaDataUpdate.Server> metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory) {
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.projectCache = projectCache;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   /**
@@ -102,7 +105,7 @@
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsers)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       AccessSection createGroupAccessSection =
           config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
       if (createGroupsGlobal.isEmpty()) {
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index e2194cc..d0bd069 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -248,10 +248,16 @@
       }
     }
 
-    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
-        && !Strings.isNullOrEmpty(who.getDisplayName())
+    if (!Strings.isNullOrEmpty(who.getDisplayName())
         && !Objects.equals(user.getAccount().getFullName(), who.getDisplayName())) {
       accountUpdates.add(u -> u.setFullName(who.getDisplayName()));
+      if (realm.allowsEdit(AccountFieldName.FULL_NAME)) {
+        accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
+      } else {
+        logger.atWarning().log(
+            "Not changing already set display name '%s' to '%s'",
+            user.getAccount().getFullName(), who.getDisplayName());
+      }
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index a9ad3b3..0a9fe81 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -115,8 +115,8 @@
     if (visitors.isEmpty()) {
       return null;
     }
-    // Include all fields from ChangeJson#toChangeInfo that are not protected by
-    // any ListChangesOptions.
+    // Include all fields from ChangeJson#toChangeInfoImpl that are not protected by any
+    // ListChangesOptions.
     ChangeInfo copy = new ChangeInfo();
     copy.project = changeInfo.project;
     copy.branch = changeInfo.branch;
@@ -128,6 +128,7 @@
     copy.mergeable = changeInfo.mergeable;
     copy.insertions = changeInfo.insertions;
     copy.deletions = changeInfo.deletions;
+    copy.hasReviewStarted = changeInfo.hasReviewStarted;
     copy.isPrivate = changeInfo.isPrivate;
     copy.subject = changeInfo.subject;
     copy.status = changeInfo.status;
@@ -135,10 +136,13 @@
     copy.created = changeInfo.created;
     copy.updated = changeInfo.updated;
     copy._number = changeInfo._number;
+    copy.requirements = changeInfo.requirements;
+    copy.revertOf = changeInfo.revertOf;
     copy.starred = changeInfo.starred;
     copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
     copy.submitter = changeInfo.submitter;
+    copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
     copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
     return copy;
@@ -148,8 +152,8 @@
     if (visitors.isEmpty()) {
       return null;
     }
-    // Include all fields from ChangeJson#toRevisionInfo that are not protected
-    // by any ListChangesOptions.
+    // Include all fields from RevisionJson#toRevisionInfo that are not protected by any
+    // ListChangesOptions.
     RevisionInfo copy = new RevisionInfo();
     copy.isCurrent = revisionInfo.isCurrent;
     copy._number = revisionInfo._number;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index c64cd12..b7049a7 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -535,6 +535,7 @@
     out.created = in.getCreatedOn();
     out.updated = in.getLastUpdatedOn();
     out._number = in.getId().get();
+    out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index 5515f0e..92ae10a 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -302,12 +302,16 @@
 
     private final GitRepositoryManager repoManager;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+    private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
     UpdateChecker(
-        GitRepositoryManager repoManager, DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+        GitRepositoryManager repoManager,
+        DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+        ProjectConfig.Factory projectConfigFactory) {
       this.repoManager = repoManager;
       this.pluginConfigEntries = pluginConfigEntries;
+      this.projectConfigFactory = projectConfigFactory;
     }
 
     @Override
@@ -361,7 +365,7 @@
         return null;
       }
       try (Repository repo = repoManager.openRepository(p)) {
-        ProjectConfig pc = new ProjectConfig(p);
+        ProjectConfig pc = projectConfigFactory.create(p);
         pc.load(repo, id);
         return pc;
       }
diff --git a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index 6fafe4e..01e85cf 100644
--- a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -54,7 +54,7 @@
   }
 
   @Override
-  public Path getBasePath(NameKey name) {
+  public Path getBasePath(Project.NameKey name) {
     Path alternateBasePath = config.getBasePath(name);
     return alternateBasePath != null ? alternateBasePath : super.getBasePath(name);
   }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 6267590..c747533 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -321,6 +321,7 @@
   private final SetHashtagsOp.Factory hashtagsFactory;
   private final SubmoduleOp.Factory subOpFactory;
   private final TagCache tagCache;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
@@ -365,6 +366,7 @@
       AccountResolver accountResolver,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config cfg,
       ChangeEditUtil editUtil,
       ChangeIndexer indexer,
@@ -437,6 +439,7 @@
     this.seq = seq;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
+    this.projectConfigFactory = projectConfigFactory;
 
     // Assisted injected fields.
     this.allRefsWatcher = allRefsWatcher;
@@ -1055,7 +1058,7 @@
       case UPDATE:
       case UPDATE_NONFASTFORWARD:
         try {
-          ProjectConfig cfg = new ProjectConfig(project.getNameKey());
+          ProjectConfig cfg = projectConfigFactory.create(project.getNameKey());
           cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:");
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 9f75c07..e3dfa75 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -98,6 +98,7 @@
     private final AccountValidator accountValidator;
     private final String installCommitMsgHookCommand;
     private final ProjectCache projectCache;
+    private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
     Factory(
@@ -110,7 +111,8 @@
         AllProjectsName allProjects,
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
-        ProjectCache projectCache) {
+        ProjectCache projectCache,
+        ProjectConfig.Factory projectConfigFactory) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.pluginValidators = pluginValidators;
@@ -122,6 +124,7 @@
       this.installCommitMsgHookCommand =
           cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
       this.projectCache = projectCache;
+      this.projectConfigFactory = projectConfigFactory;
     }
 
     public CommitValidators forReceiveCommits(
@@ -145,7 +148,7 @@
               new SignedOffByValidator(user, perm, projectState),
               new ChangeIdValidator(
                   projectState, user, urlFormatter, installCommitMsgHookCommand, sshInfo, change),
-              new ConfigValidator(branch, user, rw, allUsers, allProjects),
+              new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new BannedCommitsValidator(rejectCommits),
               new PluginCommitValidationListener(pluginValidators),
               new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
@@ -172,7 +175,7 @@
               new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())),
               new ChangeIdValidator(
                   projectState, user, urlFormatter, installCommitMsgHookCommand, sshInfo, change),
-              new ConfigValidator(branch, user, rw, allUsers, allProjects),
+              new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new PluginCommitValidationListener(pluginValidators),
               new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
               new AccountCommitValidator(repoManager, allUsers, accountValidator),
@@ -331,7 +334,8 @@
             .append(getCommitMessageHookInstallationHint())
             .append("\n")
             .append("and then amend the commit:\n")
-            .append("  git commit --amend\n");
+            .append("  git commit --amend\n")
+            .append("Finally, push your changes again\n");
       }
       return new CommitValidationMessage(sb.toString(), Type.ERROR);
     }
@@ -378,6 +382,7 @@
 
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
+    private final ProjectConfig.Factory projectConfigFactory;
     private final Branch.NameKey branch;
     private final IdentifiedUser user;
     private final RevWalk rw;
@@ -385,11 +390,13 @@
     private final AllProjectsName allProjects;
 
     public ConfigValidator(
+        ProjectConfig.Factory projectConfigFactory,
         Branch.NameKey branch,
         IdentifiedUser user,
         RevWalk rw,
         AllUsersName allUsers,
         AllProjectsName allProjects) {
+      this.projectConfigFactory = projectConfigFactory;
       this.branch = branch;
       this.user = user;
       this.rw = rw;
@@ -404,7 +411,7 @@
         List<CommitValidationMessage> messages = new ArrayList<>();
 
         try {
-          ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
+          ProjectConfig cfg = projectConfigFactory.create(receiveEvent.project.getNameKey());
           cfg.load(rw, receiveEvent.command.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:", messages);
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 0422c51..56a641f 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -127,6 +127,7 @@
     private final ProjectCache projectCache;
     private final PermissionBackend permissionBackend;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+    private final ProjectConfig.Factory projectConfigFactory;
     private final boolean allowProjectOwnersToChangeParent;
 
     public interface Factory {
@@ -140,12 +141,14 @@
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
         DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+        ProjectConfig.Factory projectConfigFactory,
         @GerritServerConfig Config config) {
       this.allProjectsName = allProjectsName;
       this.allUsersName = allUsersName;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
+      this.projectConfigFactory = projectConfigFactory;
       this.allowProjectOwnersToChangeParent =
           config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
     }
@@ -162,7 +165,7 @@
       if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
         final Project.NameKey newParent;
         try {
-          ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
+          ProjectConfig cfg = projectConfigFactory.create(destProject.getNameKey());
           cfg.load(destProject.getNameKey(), repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
           final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index eada57d..e002192 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -49,6 +49,7 @@
 
   private final ProjectCache projectCache;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   private final PersonIdent author;
   private final AccountGroup.UUID uuid;
@@ -63,6 +64,7 @@
       WorkQueue workQueue,
       ProjectCache projectCache,
       MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
       @Assisted("author") PersonIdent author,
       @Assisted AccountGroup.UUID uuid,
       @Assisted("oldName") String oldName,
@@ -70,6 +72,7 @@
     super(workQueue);
     this.projectCache = projectCache;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
 
     this.author = author;
     this.uuid = uuid;
@@ -109,7 +112,7 @@
   private void rename(MetaDataUpdate md) throws IOException, ConfigInvalidException {
     boolean success = false;
     for (int attempts = 0; !success && attempts < MAX_TRIES; attempts++) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       // The group isn't referenced, or its name has been fixed already.
       //
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 5d12e79..b79a1c2 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -508,11 +508,15 @@
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
-  /** Number of unresolved comments of the change. */
+  /** Number of unresolved comment threads of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
       intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
           .build(ChangeData::unresolvedCommentCount);
 
+  /** Total number of published inline comments of the change, including robot comments. */
+  public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
+      intRange("total_comments").build(ChangeData::totalCommentCount);
+
   /** Whether the change is mergeable. */
   public static final FieldDef<ChangeData, String> MERGEABLE =
       exact(ChangeQueryBuilder.FIELD_MERGEABLE)
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 2000cd1..9016fd1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -99,7 +99,9 @@
   @Deprecated static final Schema<ChangeData> V49 = schema(V48);
 
   // Bump Lucene version requires reindexing
-  static final Schema<ChangeData> V50 = schema(V49);
+  @Deprecated static final Schema<ChangeData> V50 = schema(V49);
+
+  static final Schema<ChangeData> V51 = schema(V50, ChangeField.TOTAL_COMMENT_COUNT);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index 1a9b859..486e264 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -195,7 +195,7 @@
     Collection<String> exports;
 
     private ClassData(Collection<String> exports) {
-      super(Opcodes.ASM6);
+      super(Opcodes.ASM7);
       this.exports = exports;
     }
 
@@ -263,7 +263,7 @@
 
   private abstract static class AbstractAnnotationVisitor extends AnnotationVisitor {
     AbstractAnnotationVisitor() {
-      super(Opcodes.ASM6);
+      super(Opcodes.ASM7);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index fc342db..4ed1c0c 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
@@ -34,6 +37,8 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 @Singleton
 public class ContributorAgreementsChecker {
@@ -93,6 +98,20 @@
       List<AccountGroup.UUID> groupIds;
       groupIds = okGroupIds;
 
+      // matchProjects defaults to match all projects when missing.
+      List<String> matchProjectsRegexes = ca.getMatchProjectsRegexes();
+      if (!matchProjectsRegexes.isEmpty()
+          && !projectMatchesAnyPattern(project.get(), matchProjectsRegexes)) {
+        // Doesn't match, isn't checked.
+        continue;
+      }
+      // excludeProjects defaults to exclude no projects when missing.
+      List<String> excludeProjectsRegexes = ca.getExcludeProjectsRegexes();
+      if (!excludeProjectsRegexes.isEmpty()
+          && projectMatchesAnyPattern(project.get(), excludeProjectsRegexes)) {
+        // Matches, isn't checked.
+        continue;
+      }
       for (PermissionRule rule : ca.getAccepted()) {
         if ((rule.getAction() == Action.ALLOW)
             && (rule.getGroup() != null)
@@ -102,7 +121,7 @@
       }
     }
 
-    if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
+    if (!okGroupIds.isEmpty() && !iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
       final StringBuilder msg = new StringBuilder();
       msg.append("No Contributor Agreement on file for user ")
           .append(iUser.getNameEmail())
@@ -114,4 +133,23 @@
       throw new AuthException(msg.toString());
     }
   }
+
+  private boolean projectMatchesAnyPattern(String projectName, List<String> regexes) {
+    checkNotNull(regexes);
+    checkArgument(!regexes.isEmpty());
+    for (String patternString : regexes) {
+      Pattern pattern;
+      try {
+        pattern = Pattern.compile(patternString);
+      } catch (PatternSyntaxException e) {
+        // Should never happen: Regular expressions validated when reading project.config.
+        throw new IllegalStateException(
+            "Invalid matchProjects or excludeProjects clause in project.config", e);
+      }
+      if (pattern.matcher(projectName).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index aa455e6..ddeeb8a 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -25,6 +25,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
@@ -89,6 +93,7 @@
   private final Lock listLock;
   private final ProjectCacheClock clock;
   private final Provider<ProjectIndexer> indexer;
+  private final Timer0 guessRelevantGroupsLatency;
 
   @Inject
   ProjectCacheImpl(
@@ -97,7 +102,8 @@
       @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
       ProjectCacheClock clock,
-      Provider<ProjectIndexer> indexer) {
+      Provider<ProjectIndexer> indexer,
+      MetricMaker metricMaker) {
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.byName = byName;
@@ -105,6 +111,13 @@
     this.listLock = new ReentrantLock(true /* fair */);
     this.clock = clock;
     this.indexer = indexer;
+
+    this.guessRelevantGroupsLatency =
+        metricMaker.newTimer(
+            "group/guess_relevant_groups_latency",
+            new Description("Latency for guessing relevant groups")
+                .setCumulative()
+                .setUnit(Units.NANOSECONDS));
   }
 
   @Override
@@ -234,15 +247,17 @@
 
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-    return all()
-        .stream()
-        .map(n -> byName.getIfPresent(n.get()))
-        .filter(Objects::nonNull)
-        .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
-        // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-        // against them just in case there is a bug or corner case.
-        .filter(id -> id != null && id.get() != null)
-        .collect(toSet());
+    try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
+      return all()
+          .stream()
+          .map(n -> byName.getIfPresent(n.get()))
+          .filter(Objects::nonNull)
+          .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+          // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+          // against them just in case there is a bug or corner case.
+          .filter(id -> id != null && id.get() != null)
+          .collect(toSet());
+    }
   }
 
   @Override
@@ -262,12 +277,18 @@
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
     private final ProjectCacheClock clock;
+    private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
-    Loader(ProjectState.Factory psf, GitRepositoryManager g, ProjectCacheClock clock) {
+    Loader(
+        ProjectState.Factory psf,
+        GitRepositoryManager g,
+        ProjectCacheClock clock,
+        ProjectConfig.Factory projectConfigFactory) {
       projectStateFactory = psf;
       mgr = g;
       this.clock = clock;
+      this.projectConfigFactory = projectConfigFactory;
     }
 
     @Override
@@ -276,7 +297,7 @@
         long now = clock.read();
         Project.NameKey key = new Project.NameKey(projectName);
         try (Repository git = mgr.openRepository(key)) {
-          ProjectConfig cfg = new ProjectConfig(key);
+          ProjectConfig cfg = projectConfigFactory.create(key);
           cfg.load(key, git);
 
           ProjectState state = projectStateFactory.create(cfg);
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index bccc415..74c0f3b 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -127,6 +128,8 @@
   private static final String KEY_ACCEPTED = "accepted";
   private static final String KEY_AUTO_VERIFY = "autoVerify";
   private static final String KEY_AGREEMENT_URL = "agreementUrl";
+  private static final String KEY_MATCH_PROJECTS = "matchProjects";
+  private static final String KEY_EXCLUDE_PROJECTS = "excludeProjects";
 
   private static final String NOTIFY = "notify";
   private static final String KEY_EMAIL = "email";
@@ -165,6 +168,29 @@
 
   private static final Pattern EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN = Pattern.compile("[, \t]{1,}");
 
+  // Don't use an assisted factory, since instances created by an assisted factory retain references
+  // to their enclosing injector. Instances of ProjectConfig are cached for a long time in the
+  // ProjectCache, so this would retain lots more memory.
+  @Singleton
+  public static class Factory {
+    public ProjectConfig create(Project.NameKey projectName) {
+      return new ProjectConfig(projectName);
+    }
+
+    public ProjectConfig read(MetaDataUpdate update) throws IOException, ConfigInvalidException {
+      ProjectConfig r = create(update.getProjectName());
+      r.load(update);
+      return r;
+    }
+
+    public ProjectConfig read(MetaDataUpdate update, ObjectId id)
+        throws IOException, ConfigInvalidException {
+      ProjectConfig r = create(update.getProjectName());
+      r.load(update, id);
+      return r;
+    }
+  }
+
   private Project project;
   private AccountsSection accountsSection;
   private GroupList groupList;
@@ -186,20 +212,6 @@
   private Map<String, List<String>> extensionPanelSections;
   private Map<String, GroupReference> groupsByName;
 
-  public static ProjectConfig read(MetaDataUpdate update)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig r = new ProjectConfig(update.getProjectName());
-    r.load(update);
-    return r;
-  }
-
-  public static ProjectConfig read(MetaDataUpdate update, ObjectId id)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig r = new ProjectConfig(update.getProjectName());
-    r.load(update, id);
-    return r;
-  }
-
   public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
@@ -241,7 +253,7 @@
     commentLinkSections.add(commentLink);
   }
 
-  public ProjectConfig(Project.NameKey projectName) {
+  private ProjectConfig(Project.NameKey projectName) {
     this.projectName = projectName;
   }
 
@@ -593,6 +605,9 @@
       ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
       ca.setAccepted(
           loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
+      ca.setExcludeProjectsRegexes(
+          loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_EXCLUDE_PROJECTS));
+      ca.setMatchProjectsRegexes(loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_MATCH_PROJECTS));
 
       List<PermissionRule> rules =
           loadPermissionRules(
@@ -753,6 +768,22 @@
     }
   }
 
+  private ImmutableList<String> loadPatterns(
+      Config rc, String section, String subsection, String varName) {
+    ImmutableList.Builder<String> patterns = ImmutableList.builder();
+    for (String patternString : rc.getStringList(section, subsection, varName)) {
+      try {
+        // While one could just use getStringList directly, compiling first will cause the server
+        // to fail fast if any of the patterns are invalid.
+        patterns.add(Pattern.compile(patternString).pattern());
+      } catch (PatternSyntaxException e) {
+        error(new ValidationError(PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+        continue;
+      }
+    }
+    return patterns.build();
+  }
+
   private ImmutableList<PermissionRule> loadPermissionRules(
       Config rc,
       String section,
@@ -1163,6 +1194,16 @@
           ca.getName(),
           KEY_ACCEPTED,
           ruleToStringList(ca.getAccepted(), keepGroups));
+      rc.setStringList(
+          CONTRIBUTOR_AGREEMENT,
+          ca.getName(),
+          KEY_EXCLUDE_PROJECTS,
+          patternToStringList(ca.getExcludeProjectsRegexes()));
+      rc.setStringList(
+          CONTRIBUTOR_AGREEMENT,
+          ca.getName(),
+          KEY_MATCH_PROJECTS,
+          patternToStringList(ca.getMatchProjectsRegexes()));
     }
   }
 
@@ -1206,6 +1247,10 @@
     }
   }
 
+  private List<String> patternToStringList(List<String> list) {
+    return list;
+  }
+
   private List<String> ruleToStringList(
       List<PermissionRule> list, Set<AccountGroup.UUID> keepGroups) {
     List<String> rules = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 11b1f37..3095f2f 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.CurrentUser;
 
 /**
@@ -57,7 +56,7 @@
     return matcher;
   }
 
-  public NameKey getProject() {
+  public Project.NameKey getProject() {
     return project;
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 83d68db..0f5d938 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -391,6 +392,7 @@
   private PersonIdent committer;
   private int parentCount;
   private Integer unresolvedCommentCount;
+  private Integer totalCommentCount;
   private LabelTypes labelTypes;
 
   private ImmutableList<byte[]> refStates;
@@ -925,6 +927,23 @@
     this.unresolvedCommentCount = count;
   }
 
+  public Integer totalCommentCount() throws OrmException {
+    if (totalCommentCount == null) {
+      if (!lazyLoad) {
+        return null;
+      }
+
+      // Fail on overflow.
+      totalCommentCount =
+          Ints.checkedCast((long) publishedComments().size() + robotComments().size());
+    }
+    return totalCommentCount;
+  }
+
+  public void setTotalCommentCount(Integer count) {
+    this.totalCommentCount = count;
+  }
+
   public List<ChangeMessage> messages() throws OrmException {
     if (messages == null) {
       if (!lazyLoad) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index f81ea15..d682b93 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -43,6 +44,7 @@
   protected final ProjectCache projectCache;
   private final Provider<AnonymousUser> anonymousUserProvider;
 
+  @Inject
   public ChangeIsVisibleToPredicate(
       Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
diff --git a/java/com/google/gerrit/server/restapi/account/GetPreferences.java b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
index a185898..3d20642 100644
--- a/java/com/google/gerrit/server/restapi/account/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -36,13 +39,18 @@
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
 
   @Inject
   GetPreferences(
-      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountCache accountCache,
+      DynamicMap<DownloadScheme> downloadSchemes) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
+    this.downloadSchemes = downloadSchemes;
   }
 
   @Override
@@ -53,9 +61,28 @@
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache
-        .get(id)
-        .map(AccountState::getGeneralPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    GeneralPreferencesInfo preferencesInfo =
+        accountCache
+            .get(id)
+            .map(AccountState::getGeneralPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return unsetDownloadSchemeIfUnsupported(preferencesInfo);
+  }
+
+  private GeneralPreferencesInfo unsetDownloadSchemeIfUnsupported(
+      GeneralPreferencesInfo preferencesInfo) {
+    if (preferencesInfo.downloadScheme == null) {
+      return preferencesInfo;
+    }
+
+    for (Extension<DownloadScheme> e : downloadSchemes) {
+      if (e.getExportName().equals(preferencesInfo.downloadScheme)
+          && e.getProvider().get().isEnabled()) {
+        return preferencesInfo;
+      }
+    }
+
+    preferencesInfo.downloadScheme = null;
+    return preferencesInfo;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 561c27c..30d93be 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
@@ -101,7 +102,8 @@
   }
 
   public ChangeResource parse(Change.Id id)
-      throws RestApiException, OrmException, PermissionBackendException, IOException {
+      throws ResourceConflictException, ResourceNotFoundException, OrmException,
+          PermissionBackendException, IOException {
     List<ChangeNotes> notes = changeFinder.find(id);
     if (notes.isEmpty()) {
       throw new ResourceNotFoundException(toIdString(id));
@@ -139,7 +141,7 @@
   }
 
   private void checkProjectStatePermitsRead(Project.NameKey project)
-      throws IOException, RestApiException {
+      throws IOException, ResourceNotFoundException, ResourceConflictException {
     ProjectState projectState = projectCache.checkedGet(project);
     if (projectState == null) {
       throw new ResourceNotFoundException("project not found: " + project.get());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 5061926..6399cde 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -29,12 +29,10 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -58,8 +56,6 @@
 import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gwtorm.server.OrmException;
@@ -109,7 +105,6 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil changeMessagesUtil;
   private final NotifyUtil notifyUtil;
 
   @Inject
@@ -126,7 +121,6 @@
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil changeMessagesUtil,
       NotifyUtil notifyUtil) {
     this.dbProvider = dbProvider;
     this.seq = seq;
@@ -140,7 +134,6 @@
     this.changeNotesFactory = changeNotesFactory;
     this.projectCache = projectCache;
     this.approvalsUtil = approvalsUtil;
-    this.changeMessagesUtil = changeMessagesUtil;
     this.notifyUtil = notifyUtil;
   }
 
@@ -155,7 +148,6 @@
     return cherryPick(
         batchUpdateFactory,
         change,
-        patch.getId(),
         change.getProject(),
         ObjectId.fromString(patch.getRevision().get()),
         input,
@@ -165,7 +157,6 @@
   public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       @Nullable Change sourceChange,
-      @Nullable PatchSet.Id sourcePatchId,
       Project.NameKey project,
       ObjectId sourceCommit,
       CherryPickInput input,
@@ -275,13 +266,6 @@
             changeId =
                 createNewChange(
                     bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
-
-            if (sourceChange != null && sourcePatchId != null) {
-              bu.addOp(
-                  sourceChange.getId(),
-                  new AddMessageToSourceChangeOp(
-                      changeMessagesUtil, sourcePatchId, dest.getShortName(), cherryPickCommit));
-            }
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -369,6 +353,9 @@
             messageForDestinationChange(
                 ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit))
         .setTopic(topic)
+        .setWorkInProgress(
+            (sourceChange != null && sourceChange.isWorkInProgress())
+                || !cherryPickCommit.getFilesWithGitConflicts().isEmpty())
         .setNotify(input.notify)
         .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
     if (input.keepReviewers && sourceChange != null) {
@@ -387,43 +374,6 @@
     return changeId;
   }
 
-  private static class AddMessageToSourceChangeOp implements BatchUpdateOp {
-    private final ChangeMessagesUtil cmUtil;
-    private final PatchSet.Id psId;
-    private final String destBranch;
-    private final ObjectId cherryPickCommit;
-
-    private AddMessageToSourceChangeOp(
-        ChangeMessagesUtil cmUtil, PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
-      this.cmUtil = cmUtil;
-      this.psId = psId;
-      this.destBranch = destBranch;
-      this.cherryPickCommit = cherryPickCommit;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      StringBuilder sb =
-          new StringBuilder("Patch Set ")
-              .append(psId.get())
-              .append(": Cherry Picked")
-              .append("\n\n")
-              .append("This patchset was cherry picked to branch ")
-              .append(destBranch)
-              .append(" as commit ")
-              .append(cherryPickCommit.name());
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              psId,
-              ctx.getUser(),
-              ctx.getWhen(),
-              sb.toString(),
-              ChangeMessagesUtil.TAG_CHERRY_PICK_CHANGE);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
-      return true;
-    }
-  }
-
   private String messageForDestinationChange(
       PatchSet.Id patchSetId,
       Branch.NameKey sourceBranch,
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
index d18b172..f76689c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -100,7 +100,6 @@
           cherryPickChange.cherryPick(
               updateFactory,
               null,
-              null,
               projectName,
               commit,
               input,
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 013d3e9..df1d9b9 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -47,6 +48,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -68,6 +70,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -87,6 +90,7 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ProjectCache projectCache;
+  private final boolean moveEnabled;
 
   @Inject
   Move(
@@ -99,7 +103,8 @@
       RetryHelper retryHelper,
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      @GerritServerConfig Config gerritConfig) {
     super(retryHelper);
     this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
@@ -110,6 +115,7 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.projectCache = projectCache;
+    this.moveEnabled = gerritConfig.getBoolean("change", null, "move", true);
   }
 
   @Override
@@ -117,6 +123,12 @@
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
       throws RestApiException, OrmException, UpdateException, PermissionBackendException,
           IOException {
+    if (!moveEnabled) {
+      // This will be removed with the above config once we reach consensus for the move change
+      // behavior. See: https://bugs.chromium.org/p/gerrit/issues/detail?id=9877
+      throw new MethodNotAllowedException("move changes endpoint is disabled");
+    }
+
     Change change = rsrc.getChange();
     Project.NameKey project = rsrc.getProject();
     IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index feb37c0..f679610 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -68,6 +68,7 @@
   private final SetAccessUtil setAccess;
   private final ChangeJson.Factory jsonFactory;
   private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   CreateAccessChange(
@@ -79,7 +80,8 @@
       Provider<ReviewDb> db,
       SetAccessUtil accessUtil,
       ChangeJson.Factory jsonFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ProjectConfig.Factory projectConfigFactory) {
     this.permissionBackend = permissionBackend;
     this.seq = seq;
     this.changeInserterFactory = changeInserterFactory;
@@ -89,6 +91,7 @@
     this.setAccess = accessUtil;
     this.jsonFactory = jsonFactory;
     this.projectCache = projectCache;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -117,7 +120,7 @@
         input.parent == null ? null : new Project.NameKey(input.parent);
 
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       ObjectId oldCommit = config.getRevision();
       String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 5620370..7773914 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -115,6 +115,7 @@
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
   private final PluginItemContext<ProjectNameLockManager> lockManager;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   CreateProject(
@@ -135,7 +136,8 @@
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      PluginItemContext<ProjectNameLockManager> lockManager) {
+      PluginItemContext<ProjectNameLockManager> lockManager,
+      ProjectConfig.Factory projectConfigFactory) {
     this.projectsCollection = projectsCollection;
     this.groupResolver = groupResolver;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -154,6 +156,7 @@
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.lockManager = lockManager;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -286,7 +289,7 @@
   private void createProjectConfig(CreateProjectArgs args)
       throws IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       Project newProject = config.getProject();
       newProject.setDescription(args.projectDescription);
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index a6b9404..8875d40 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -95,6 +95,7 @@
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final GroupBackend groupBackend;
   private final WebLinks webLinks;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   public GetAccess(
@@ -105,7 +106,8 @@
       MetaDataUpdate.Server metaDataUpdateFactory,
       ProjectJson projectJson,
       GroupBackend groupBackend,
-      WebLinks webLinks) {
+      WebLinks webLinks,
+      ProjectConfig.Factory projectConfigFactory) {
     this.user = self;
     this.permissionBackend = permissionBackend;
     this.allProjectsName = allProjectsName;
@@ -114,6 +116,7 @@
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.groupBackend = groupBackend;
     this.webLinks = webLinks;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   public ProjectAccessInfo apply(Project.NameKey nameKey)
@@ -141,7 +144,7 @@
 
     ProjectConfig config;
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      config = ProjectConfig.read(md);
+      config = projectConfigFactory.read(md);
       info.configWebLinks = new ArrayList<>();
 
       // config may have a null revision if the repo doesn't have its own refs/meta/config.
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 76ea0c9..921a591 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.ProjectState.Factory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -77,20 +78,22 @@
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
   private final PermissionBackend permissionBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   PutConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
-      ProjectState.Factory projectStateFactory,
+      Factory projectStateFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
@@ -102,6 +105,7 @@
     this.views = views;
     this.user = user;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -122,7 +126,7 @@
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig projectConfig = ProjectConfig.read(md);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
       Project p = projectConfig.getProject();
 
       p.setDescription(Strings.emptyToNull(input.description));
@@ -164,7 +168,7 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(ProjectConfig.read(md));
+      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md));
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index 1c74021..081669a 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -42,15 +42,18 @@
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
   private final PermissionBackend permissionBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   PutDescription(
       ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -68,7 +71,7 @@
         .check(ProjectPermission.WRITE_CONFIG);
 
     try (MetaDataUpdate md = updateFactory.create(resource.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       project.setDescription(Strings.emptyToNull(input.description));
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index c9d69a5..19e89a9 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -56,6 +56,7 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   private SetAccess(
@@ -66,7 +67,8 @@
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
-      CreateGroupPermissionSyncer createGroupPermissionSyncer) {
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
+      ProjectConfig.Factory projectConfigFactory) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -75,6 +77,7 @@
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -89,7 +92,7 @@
     List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
     List<AccessSection> additions = accessUtil.getAccessSections(input.add);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      config = ProjectConfig.read(md);
+      config = projectConfigFactory.read(md);
 
       // Check that the user has the right permissions.
       boolean checkedAdmin = false;
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index ef292e5..0f346df 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -47,6 +47,7 @@
   private final DashboardsCollection dashboards;
   private final Provider<GetDashboard> get;
   private final PermissionBackend permissionBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
   boolean inherited;
@@ -57,12 +58,14 @@
       MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
       Provider<GetDashboard> get,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.dashboards = dashboards;
     this.get = get;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -93,7 +96,7 @@
     }
 
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       if (inherited) {
         project.setDefaultDashboard(input.id);
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index ca7e7aa..15cb7f8 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -60,6 +60,7 @@
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
+  private final ProjectConfig.Factory projectConfigFactory;
   private volatile boolean allowProjectOwnersToChangeParent;
 
   @Inject
@@ -69,12 +70,14 @@
       MetaDataUpdate.Server updateFactory,
       AllProjectsName allProjects,
       AllUsersName allUsers,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config config) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
+    this.projectConfigFactory = projectConfigFactory;
     this.allowProjectOwnersToChangeParent =
         config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
   }
@@ -96,7 +99,7 @@
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       project.setParentName(parentName);
 
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 8b9cfe3..a712635 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -110,6 +110,18 @@
         }
       };
 
+  // Accessing GitRepositoryManager could be slow.
+  // It should be minimized or cached to reduce pause time
+  // when evaluating Prolog submit rules.
+  public static final StoredValue<GitRepositoryManager> REPO_MANAGER =
+      new StoredValue<GitRepositoryManager>() {
+        @Override
+        public GitRepositoryManager createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getGitRepositoryManager();
+        }
+      };
+
   public static final StoredValue<Repository> REPOSITORY =
       new StoredValue<Repository>() {
         @Override
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 348f88c..85965ef 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -73,6 +73,7 @@
   private final AllProjectsName allProjectsName;
   private final PersonIdent serverUser;
   private final NotesMigration notesMigration;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final GroupReference anonymous;
   private final GroupReference registered;
   private final GroupReference owners;
@@ -90,11 +91,13 @@
       AllProjectsName allProjectsName,
       @GerritPersonIdent PersonIdent serverUser,
       NotesMigration notesMigration,
-      SystemGroupBackend systemGroupBackend) {
+      SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.repositoryManager = repositoryManager;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
     this.notesMigration = notesMigration;
+    this.projectConfigFactory = projectConfigFactory;
 
     this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
@@ -170,7 +173,7 @@
               Strings.emptyToNull(message),
               "Initialized Gerrit Code Review " + Version.getVersion()));
 
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project p = config.getProject();
       p.setDescription("Access inherited by all other projects.");
       p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.TRUE);
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 3779d0d..3b1124d 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectConfig.Factory;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -50,6 +51,7 @@
   private final GitRepositoryManager mgr;
   private final AllUsersName allUsersName;
   private final PersonIdent serverUser;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final GroupReference registered;
 
   @Nullable private GroupReference admin;
@@ -60,11 +62,13 @@
       GitRepositoryManager mgr,
       AllUsersName allUsersName,
       SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
+      @GerritPersonIdent PersonIdent serverUser,
+      Factory projectConfigFactory) {
     this.mgr = mgr;
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+    this.projectConfigFactory = projectConfigFactory;
     this.codeReviewLabel = getDefaultCodeReviewLabel();
   }
 
@@ -107,7 +111,7 @@
       md.getCommitBuilder().setCommitter(serverUser);
       md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
 
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       project.setDescription("Individual user settings and preferences.");
 
diff --git a/java/com/google/gerrit/server/schema/Schema_108.java b/java/com/google/gerrit/server/schema/Schema_108.java
index 4e62460..b37fab3 100644
--- a/java/com/google/gerrit/server/schema/Schema_108.java
+++ b/java/com/google/gerrit/server/schema/Schema_108.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -145,8 +144,8 @@
 
   private SetMultimap<Project.NameKey, Change.Id> getOpenChangesByProject(ReviewDb db, UpdateUI ui)
       throws OrmException {
-    SortedSet<NameKey> projects = repoManager.list();
-    SortedSet<NameKey> nonExistentProjects = Sets.newTreeSet();
+    SortedSet<Project.NameKey> projects = repoManager.list();
+    SortedSet<Project.NameKey> nonExistentProjects = Sets.newTreeSet();
     SetMultimap<Project.NameKey, Change.Id> openByProject =
         MultimapBuilder.hashKeys().hashSetValues().build();
     for (Change c : db.changes().all()) {
@@ -155,7 +154,7 @@
         continue;
       }
 
-      NameKey projectKey = c.getProject();
+      Project.NameKey projectKey = c.getProject();
       if (!projects.contains(projectKey)) {
         nonExistentProjects.add(projectKey);
       } else {
diff --git a/java/com/google/gerrit/server/schema/Schema_120.java b/java/com/google/gerrit/server/schema/Schema_120.java
index f2f3b99..15e34ab 100644
--- a/java/com/google/gerrit/server/schema/Schema_120.java
+++ b/java/com/google/gerrit/server/schema/Schema_120.java
@@ -42,15 +42,18 @@
 public class Schema_120 extends SchemaVersion {
 
   private final GitRepositoryManager mgr;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
   Schema_120(
       Provider<Schema_119> prior,
       GitRepositoryManager mgr,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.mgr = mgr;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -64,7 +67,7 @@
         md.getCommitBuilder().setAuthor(serverUser);
         md.getCommitBuilder().setCommitter(serverUser);
         md.setMessage("Added superproject subscription during upgrade");
-        ProjectConfig pc = ProjectConfig.read(md);
+        ProjectConfig pc = projectConfigFactory.read(md);
 
         SubscribeSection s = null;
         for (SubscribeSection s1 : pc.getSubscribeSections(subbranch)) {
diff --git a/java/com/google/gerrit/server/schema/Schema_125.java b/java/com/google/gerrit/server/schema/Schema_125.java
index 7aab7c7..474d7ac 100644
--- a/java/com/google/gerrit/server/schema/Schema_125.java
+++ b/java/com/google/gerrit/server/schema/Schema_125.java
@@ -57,6 +57,7 @@
   private final AllUsersName allUsersName;
   private final AllProjectsName allProjectsName;
   private final SystemGroupBackend systemGroupBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -66,12 +67,14 @@
       AllUsersName allUsersName,
       AllProjectsName allProjectsName,
       SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.allProjectsName = allProjectsName;
     this.systemGroupBackend = systemGroupBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -79,7 +82,7 @@
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (Repository git = repoManager.openRepository(allUsersName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       config
           .getAccessSection(RefNames.REFS_USERS + "*", true)
@@ -114,7 +117,7 @@
     while (parent != null) {
       try (Repository git = repoManager.openRepository(parent);
           MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, parent, git)) {
-        ProjectConfig parentConfig = ProjectConfig.read(md);
+        ProjectConfig parentConfig = projectConfigFactory.read(md);
         for (LabelType lt : parentConfig.getLabelSections().values()) {
           if (!labelTypes.containsKey(lt.getName())) {
             labelTypes.put(lt.getName(), lt);
diff --git a/java/com/google/gerrit/server/schema/Schema_126.java b/java/com/google/gerrit/server/schema/Schema_126.java
index 5dbda72..23de169 100644
--- a/java/com/google/gerrit/server/schema/Schema_126.java
+++ b/java/com/google/gerrit/server/schema/Schema_126.java
@@ -44,6 +44,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final SystemGroupBackend systemGroupBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -52,11 +53,13 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.systemGroupBackend = systemGroupBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -64,7 +67,7 @@
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (Repository git = repoManager.openRepository(allUsersName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       String refsUsersShardedId = RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
       config.remove(config.getAccessSection(refsUsersShardedId));
diff --git a/java/com/google/gerrit/server/schema/Schema_128.java b/java/com/google/gerrit/server/schema/Schema_128.java
index bd6b76a..4f5f962 100644
--- a/java/com/google/gerrit/server/schema/Schema_128.java
+++ b/java/com/google/gerrit/server/schema/Schema_128.java
@@ -42,6 +42,7 @@
   private final GitRepositoryManager repoManager;
   private final AllProjectsName allProjectsName;
   private final SystemGroupBackend systemGroupBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -50,11 +51,13 @@
       GitRepositoryManager repoManager,
       AllProjectsName allProjectsName,
       SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allProjectsName = allProjectsName;
     this.systemGroupBackend = systemGroupBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -63,7 +66,7 @@
     try (Repository git = repoManager.openRepository(allProjectsName);
         MetaDataUpdate md =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
       AccessSection refsFor = config.getAccessSection("refs/for/*", true);
diff --git a/java/com/google/gerrit/server/schema/Schema_131.java b/java/com/google/gerrit/server/schema/Schema_131.java
index 3755211..e496096 100644
--- a/java/com/google/gerrit/server/schema/Schema_131.java
+++ b/java/com/google/gerrit/server/schema/Schema_131.java
@@ -38,15 +38,18 @@
       "Rename 'Push Annotated/Signed Tag' permission to 'Create Annotated/Signed Tag'";
 
   private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
   Schema_131(
       Provider<Schema_130> prior,
       GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -58,7 +61,7 @@
     for (Project.NameKey projectName : repoList) {
       try (Repository git = repoManager.openRepository(projectName);
           MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, git)) {
-        ProjectConfig config = ProjectConfig.read(md);
+        ProjectConfig config = projectConfigFactory.read(md);
         if (config.hasLegacyPermissions()) {
           md.getCommitBuilder().setAuthor(serverUser);
           md.getCommitBuilder().setCommitter(serverUser);
diff --git a/java/com/google/gerrit/server/schema/Schema_135.java b/java/com/google/gerrit/server/schema/Schema_135.java
index 66224c2..6281251 100644
--- a/java/com/google/gerrit/server/schema/Schema_135.java
+++ b/java/com/google/gerrit/server/schema/Schema_135.java
@@ -48,6 +48,7 @@
   private final GitRepositoryManager repoManager;
   private final AllProjectsName allProjectsName;
   private final SystemGroupBackend systemGroupBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -56,11 +57,13 @@
       GitRepositoryManager repoManager,
       AllProjectsName allProjectsName,
       SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allProjectsName = allProjectsName;
     this.systemGroupBackend = systemGroupBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -69,7 +72,7 @@
     try (Repository git = repoManager.openRepository(allProjectsName);
         MetaDataUpdate md =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
       Permission createRefsMetaConfigPermission = meta.getPermission(Permission.CREATE, true);
diff --git a/java/com/google/gerrit/server/schema/Schema_162.java b/java/com/google/gerrit/server/schema/Schema_162.java
index 7406bc6..85220c7 100644
--- a/java/com/google/gerrit/server/schema/Schema_162.java
+++ b/java/com/google/gerrit/server/schema/Schema_162.java
@@ -34,6 +34,7 @@
   private final GitRepositoryManager repoManager;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -42,11 +43,13 @@
       GitRepositoryManager repoManager,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -54,7 +57,7 @@
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (Repository git = repoManager.openRepository(allUsersName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig cfg = ProjectConfig.read(md);
+      ProjectConfig cfg = projectConfigFactory.read(md);
       if (allProjectsName.equals(cfg.getProject().getParent(allProjectsName))) {
         return;
       }
diff --git a/java/com/google/gerrit/server/schema/Schema_164.java b/java/com/google/gerrit/server/schema/Schema_164.java
index 8525478..818f84c 100644
--- a/java/com/google/gerrit/server/schema/Schema_164.java
+++ b/java/com/google/gerrit/server/schema/Schema_164.java
@@ -44,6 +44,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final SystemGroupBackend systemGroupBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -52,11 +53,13 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.systemGroupBackend = systemGroupBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -68,7 +71,7 @@
       md.getCommitBuilder().setCommitter(serverUser);
       md.setMessage(COMMIT_MSG);
 
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
       grant(
           config,
diff --git a/java/com/google/gerrit/server/schema/Schema_165.java b/java/com/google/gerrit/server/schema/Schema_165.java
index cd6da55..b4f9523 100644
--- a/java/com/google/gerrit/server/schema/Schema_165.java
+++ b/java/com/google/gerrit/server/schema/Schema_165.java
@@ -48,6 +48,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final SystemGroupBackend systemGroupBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -56,11 +57,13 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.systemGroupBackend = systemGroupBackend;
+    this.projectConfigFactory = projectConfigFactory;
     this.serverUser = serverUser;
   }
 
@@ -68,7 +71,7 @@
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     try (Repository git = repoManager.openRepository(allUsersName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Optional<Permission> permission = findDefaultPermission(config);
       if (!permission.isPresent()) {
         // the default permission was not found, hence it cannot be fixed
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 9efb976..66463be 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gwtorm.server.OrmException;
@@ -87,20 +88,23 @@
 
   private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final Map<QueryKey, List<ChangeData>> queryCache;
+  private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
   private final Map<Branch.NameKey, Optional<RevCommit>> heads;
   private final ProjectCache projectCache;
+  private final ChangeIsVisibleToPredicate changeIsVisibleToPredicate;
 
   @Inject
   LocalMergeSuperSetComputation(
       PermissionBackend permissionBackend,
       Provider<InternalChangeQuery> queryProvider,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeIsVisibleToPredicate changeIsVisibleToPredicate) {
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
+    this.changeIsVisibleToPredicate = changeIsVisibleToPredicate;
   }
 
   @Override
@@ -147,10 +151,11 @@
 
       Set<String> visibleHashes =
           walkChangesByHashes(visibleCommits, Collections.emptySet(), or, b);
-      Iterables.addAll(visibleChanges, byCommitsOnBranchNotMerged(or, db, b, visibleHashes));
-
       Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
-      Iterables.addAll(nonVisibleChanges, byCommitsOnBranchNotMerged(or, db, b, nonVisibleHashes));
+
+      ChangeSet partialSet = byCommitsOnBranchNotMerged(or, db, b, visibleHashes, nonVisibleHashes);
+      Iterables.addAll(visibleChanges, partialSet.changes());
+      Iterables.addAll(nonVisibleChanges, partialSet.nonVisibleChanges());
     }
 
     return new ChangeSet(visibleChanges, nonVisibleChanges);
@@ -206,24 +211,41 @@
     return str.type;
   }
 
-  private List<ChangeData> byCommitsOnBranchNotMerged(
+  private ChangeSet byCommitsOnBranchNotMerged(
+      OpenRepo or,
+      ReviewDb db,
+      Branch.NameKey branch,
+      Set<String> visibleHashes,
+      Set<String> nonVisibleHashes)
+      throws OrmException, IOException {
+    List<ChangeData> potentiallyVisibleChanges =
+        byCommitsOnBranchNotMerged(or, db, branch, visibleHashes);
+    List<ChangeData> invisibleChanges =
+        new ArrayList<>(byCommitsOnBranchNotMerged(or, db, branch, nonVisibleHashes));
+    List<ChangeData> visibleChanges = new ArrayList<>(potentiallyVisibleChanges.size());
+    for (ChangeData cd : potentiallyVisibleChanges) {
+      if (changeIsVisibleToPredicate.match(cd)) {
+        visibleChanges.add(cd);
+      } else {
+        invisibleChanges.add(cd);
+      }
+    }
+    return new ChangeSet(visibleChanges, invisibleChanges);
+  }
+
+  private ImmutableList<ChangeData> byCommitsOnBranchNotMerged(
       OpenRepo or, ReviewDb db, Branch.NameKey branch, Set<String> hashes)
       throws OrmException, IOException {
     if (hashes.isEmpty()) {
       return ImmutableList.of();
     }
     QueryKey k = QueryKey.create(branch, hashes);
-    List<ChangeData> cached = queryCache.get(k);
-    if (cached != null) {
-      return cached;
+    if (queryCache.containsKey(k)) {
+      return queryCache.get(k);
     }
-
-    List<ChangeData> result = new ArrayList<>();
-    Iterable<ChangeData> destChanges =
-        queryProvider.get().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
-    for (ChangeData chd : destChanges) {
-      result.add(chd);
-    }
+    ImmutableList<ChangeData> result =
+        ImmutableList.copyOf(
+            queryProvider.get().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes));
     queryCache.put(k, result);
     return result;
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4cefd7d..6511d25 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
@@ -115,6 +116,7 @@
     final OnSubmitValidators.Factory onSubmitValidatorsFactory;
     final TagCache tagCache;
     final Provider<InternalChangeQuery> queryProvider;
+    final ProjectConfig.Factory projectConfigFactory;
 
     final Branch.NameKey destBranch;
     final CodeReviewRevWalk rw;
@@ -154,6 +156,7 @@
         OnSubmitValidators.Factory onSubmitValidatorsFactory,
         TagCache tagCache,
         Provider<InternalChangeQuery> queryProvider,
+        ProjectConfig.Factory projectConfigFactory,
         @Assisted Branch.NameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
@@ -176,6 +179,7 @@
       this.repoManager = repoManager;
       this.cmUtil = cmUtil;
       this.labelNormalizer = labelNormalizer;
+      this.projectConfigFactory = projectConfigFactory;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.psUtil = psUtil;
       this.projectCache = projectCache;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 8a4fbfb..3be4c31 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -145,7 +145,7 @@
     if (RefNames.REFS_CONFIG.equals(refName)) {
       logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
       try {
-        ProjectConfig cfg = new ProjectConfig(getProject());
+        ProjectConfig cfg = args.projectConfigFactory.create(getProject());
         cfg.load(ctx.getRevWalk(), commit);
       } catch (Exception e) {
         throw new IntegrationException(
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index d5b44b5..bc8ef2a 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -250,9 +250,6 @@
   }
 
   private void reviewPatchSet(PatchSet patchSet) throws Exception {
-    if (notify == null) {
-      notify = NotifyHandling.ALL;
-    }
 
     ReviewInput review = new ReviewInput();
     review.message = Strings.emptyToNull(changeComment);
diff --git a/java/com/google/gerrit/testing/NoteDbMode.java b/java/com/google/gerrit/testing/NoteDbMode.java
index d4a7c7e..e46acc3 100644
--- a/java/com/google/gerrit/testing/NoteDbMode.java
+++ b/java/com/google/gerrit/testing/NoteDbMode.java
@@ -52,7 +52,7 @@
       value = System.getProperty(SYS_PROP);
     }
     if (Strings.isNullOrEmpty(value)) {
-      return OFF;
+      return ON;
     }
     value = value.toUpperCase().replace("-", "_");
     NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index f04cefc..5e46a03 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -103,9 +103,11 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.ProjectWatches;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
@@ -229,6 +231,8 @@
   @Inject
   private DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners;
 
+  @Inject private AccountManager accountManager;
+
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
@@ -2716,6 +2720,18 @@
     }
   }
 
+  @Test
+  public void updateDisplayName() throws Exception {
+    String name = name("test");
+    gApi.accounts().create(name);
+    AuthRequest who = AuthRequest.forUser(name);
+    accountManager.authenticate(who);
+    assertThat(gApi.accounts().id(name).get().name).isEqualTo(name);
+    who.setDisplayName("Something Else");
+    accountManager.authenticate(who);
+    assertThat(gApi.accounts().id(name).get().name).isEqualTo("Something Else");
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 7a4a901..c939ac1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -182,6 +182,28 @@
   }
 
   @Test
+  public void revertExcludedProjectChangeWithoutCLA() throws Exception {
+    // Contributor agreements configured with excludeProjects = ExcludedProject
+    // in AbstractDaemonTest.configureContributorAgreement(...)
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    // Project name includes test method name which contains ExcludedProject
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    setApiUser(admin);
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Revert in excluded project is allowed even when CLA is required but not signed
+    setApiUser(user);
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    gApi.changes().id(change.changeId).revert();
+  }
+
+  @Test
   public void cherrypickChangeWithoutCLA() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
 
@@ -240,6 +262,17 @@
     gApi.changes().create(newChangeInput());
   }
 
+  @Test
+  public void createExcludedProjectChangeIgnoresCLA() throws Exception {
+    // Contributor agreements configured with excludeProjects = ExcludedProject
+    // in AbstractDaemonTest.configureContributorAgreement(...)
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change in excluded project is allowed even when CLA is required but not signed.
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    gApi.changes().create(newChangeInput());
+  }
+
   private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
     assertThat(info.name).isEqualTo(ca.getName());
     assertThat(info.description).isEqualTo(ca.getDescription());
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 946e15c..24040a4 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -30,7 +30,13 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.inject.Inject;
+import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.HashMap;
 import org.junit.Before;
@@ -38,6 +44,8 @@
 
 @NoHttpd
 public class GeneralPreferencesIT extends AbstractDaemonTest {
+  @Inject private DynamicMap<DownloadScheme> downloadSchemes;
+
   private TestAccount user42;
 
   @Before
@@ -177,4 +185,66 @@
     GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
     assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
   }
+
+  @Test
+  public void rejectUnsupportedDownloadScheme() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.downloadScheme = "foo";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Unsupported download scheme: " + i.downloadScheme);
+    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void setDownloadScheme() throws Exception {
+    String schemeName = "foo";
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<DownloadScheme>) downloadSchemes)
+            .put("myPlugin", schemeName, Providers.of(new TestDownloadScheme()));
+    try {
+      GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+      i.downloadScheme = schemeName;
+
+      GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+      assertThat(o.downloadScheme).isEqualTo(schemeName);
+
+      o = gApi.accounts().id(user42.getId().toString()).getPreferences();
+      assertThat(o.downloadScheme).isEqualTo(schemeName);
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void unsupportedDownloadSchemeIsNotReturned() throws Exception {
+    // Set a download scheme and unregister the plugin that provides this download scheme so that it
+    // becomes unsupported.
+    setDownloadScheme();
+
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).getPreferences();
+    assertThat(o.downloadScheme).isNull();
+  }
+
+  private static class TestDownloadScheme extends DownloadScheme {
+    @Override
+    public String getUrl(String project) {
+      return "http://foo/" + project;
+    }
+
+    @Override
+    public boolean isAuthRequired() {
+      return false;
+    }
+
+    @Override
+    public boolean isAuthSupported() {
+      return false;
+    }
+
+    @Override
+    public boolean isEnabled() {
+      return true;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 7dce600..ed4137d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -125,6 +125,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -140,8 +141,12 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -186,6 +191,9 @@
 
   @Inject private AccountOperations accountOperations;
 
+  @Inject private ChangeIndexCollection changeIndexCollection;
+  @Inject private IndexConfig indexConfig;
+
   private ChangeIndexedCounter changeIndexedCounter;
   private RegistrationHandle changeIndexedCounterHandle;
 
@@ -1309,6 +1317,23 @@
   }
 
   @Test
+  public void deleteChangeUpdatesIndex() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    ChangeIndex idx = changeIndexCollection.getSearchIndex();
+
+    Optional<ChangeData> result =
+        idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
+
+    assertThat(result.isPresent()).isTrue();
+    gApi.changes().id(changeId).delete();
+    result = idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
+    assertThat(result.isPresent()).isFalse();
+  }
+
+  @Test
   public void rebaseUpToDateChange() throws Exception {
     PushOneCommit.Result r = createChange();
     exception.expect(ResourceConflictException.class);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 9fcbaa7..995f89b 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
@@ -51,7 +52,10 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.project.CommentLinkInfoImpl;
 import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
@@ -61,6 +65,13 @@
 
 @NoHttpd
 public class ProjectIT extends AbstractDaemonTest {
+  private static final String BUGZILLA = "bugzilla";
+  private static final String BUGZILLA_LINK = "http://bugzilla.example.com/?id=$2";
+  private static final String BUGZILLA_MATCH = "(bug\\\\s+#?)(\\\\d+)";
+  private static final String JIRA = "jira";
+  private static final String JIRA_LINK = "http://jira.example.com/?id=$2";
+  private static final String JIRA_MATCH = "(jira\\\\s+#?)(\\\\d+)";
+
   @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
 
   @Inject
@@ -603,6 +614,31 @@
     setMaxObjectSize("100 foo");
   }
 
+  @Test
+  public void noCommentlinksByDefault() throws Exception {
+    assertThat(getConfig().commentlinks).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
+  @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
+  @GerritConfig(name = "commentlink.jira.match", value = JIRA_MATCH)
+  @GerritConfig(name = "commentlink.jira.link", value = JIRA_LINK)
+  public void projectConfigUsesCommentlinksFromGlobalConfig() throws Exception {
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
+    return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
+  }
+
+  private void assertCommentLinks(ConfigInfo actual, Map<String, CommentLinkInfo> expected) {
+    assertThat(actual.commentlinks).containsExactlyEntriesIn(expected);
+  }
+
   private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
     return gApi.projects().name(name.get()).config(input);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index e5906a2..bde042f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -320,27 +320,14 @@
     assertThat(orig.get().messages).hasSize(1);
     CherryPickChangeInfo changeInfo = orig.revision(r.getCommit().name()).cherryPickAsInfo(in);
     assertThat(changeInfo.containsGitConflicts).isNull();
+    assertThat(changeInfo.workInProgress).isNull();
     ChangeApi cherry = gApi.changes().id(changeInfo._number);
 
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
-    assertThat(messages).hasSize(2);
-
-    String cherryPickedRevision = cherry.get().currentRevision;
-    String expectedMessage =
-        String.format(
-            "Patch Set 1: Cherry Picked\n\n"
-                + "This patchset was cherry picked to branch %s as commit %s",
-            in.destination, cherryPickedRevision);
-
-    Iterator<ChangeMessageInfo> origIt = messages.iterator();
-    origIt.next();
-    assertThat(origIt.next().message).isEqualTo(expectedMessage);
-
-    assertThat(cherry.get().messages).hasSize(1);
-    Iterator<ChangeMessageInfo> cherryIt = cherry.get().messages.iterator();
-    expectedMessage = "Patch Set 1: Cherry Picked from branch master.";
-    assertThat(cherryIt.next().message).isEqualTo(expectedMessage);
+    ChangeInfo cherryPickChangeInfoWithDetails = cherry.get();
+    assertThat(cherryPickChangeInfoWithDetails.workInProgress).isNull();
+    assertThat(cherryPickChangeInfoWithDetails.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeInfoWithDetails.messages.iterator();
+    assertThat(cherryIt.next().message).isEqualTo("Patch Set 1: Cherry Picked from branch master.");
 
     assertThat(cherry.get().subject).contains(in.message);
     assertThat(cherry.get().topic).isEqualTo("someTopic-foo");
@@ -386,6 +373,19 @@
   }
 
   @Test
+  public void cherryPickWorkInProgressChange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "cherry pick message";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().workInProgress).isTrue();
+  }
+
+  @Test
   public void cherryPickToSameBranch() throws Exception {
     PushOneCommit.Result r = createChange();
     CherryPickInput in = new CherryPickInput();
@@ -457,10 +457,6 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
 
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
-    assertThat(messages).hasSize(2);
-
     assertThat(cherry.get().subject).contains(in.message);
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
@@ -546,6 +542,7 @@
     CherryPickChangeInfo cherryPickChange =
         changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
     assertThat(cherryPickChange.containsGitConflicts).isTrue();
+    assertThat(cherryPickChange.workInProgress).isTrue();
 
     // Verify that subject and topic on the cherry-pick change have been correctly populated.
     assertThat(cherryPickChange.subject).contains(in.message);
@@ -580,20 +577,7 @@
 
     // Get details of cherry-pick change.
     ChangeInfo cherryPickChangeWithDetails = gApi.changes().id(cherryPickChange._number).get();
-
-    // Verify that a message has been posted on the original change.
-    String cherryPickedRevision = cherryPickChangeWithDetails.currentRevision;
-    changeApi = gApi.changes().id(r.getChange().getId().get());
-    Collection<ChangeMessageInfo> messages = changeApi.get().messages;
-    assertThat(messages).hasSize(2);
-    Iterator<ChangeMessageInfo> origIt = messages.iterator();
-    origIt.next();
-    assertThat(origIt.next().message)
-        .isEqualTo(
-            String.format(
-                "Patch Set 1: Cherry Picked\n\n"
-                    + "This patchset was cherry picked to branch %s as commit %s",
-                in.destination, cherryPickedRevision));
+    assertThat(cherryPickChangeWithDetails.workInProgress).isTrue();
 
     // Verify that a message has been posted on the cherry-pick change.
     assertThat(cherryPickChangeWithDetails.messages).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index cd20765..d713db6 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -1024,7 +1024,7 @@
   }
 
   @Test
-  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+  public void queryChangesWithCommentCounts() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
 
     PushOneCommit.Result r1 = createChange();
@@ -1043,6 +1043,7 @@
       // if we allow users to resolve a robot comment, then this test should
       // be modified.
       assertThat(result.unresolvedCommentCount).isEqualTo(0);
+      assertThat(result.totalCommentCount).isEqualTo(1);
     } finally {
       enableDb(ctx);
     }
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 91a1278..9ad14ee 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -239,6 +239,7 @@
 
   @Test
   public void publishEditRestWithoutCLA() throws Exception {
+    configureContributorAgreement(true);
     createArbitraryEditFor(changeId);
     setUseContributorAgreements(InheritableBoolean.TRUE);
     adminRestSession.post(urlPublish(changeId)).assertForbidden();
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 943b052..66a09f8 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -189,7 +189,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(sub)) {
       md.setMessage("Added superproject subscription");
       SubscribeSection s;
-      ProjectConfig pc = ProjectConfig.read(md);
+      ProjectConfig pc = projectConfigFactory.read(md);
       if (pc.getSubscribeSections().containsKey(superName)) {
         s = pc.getSubscribeSections().get(superName);
       } else {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
index a2ad7fc..3f1608c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.reviewdb.client.Change;
 import org.junit.Test;
 
 public class ChangeIdIT extends AbstractDaemonTest {
@@ -92,6 +93,43 @@
     res.assertNotFound();
   }
 
+  @Test
+  public void changeNumberRedirects() throws Exception {
+    // This test tests a redirect that is primarily intended for the UI (though the backend doesn't
+    // really care who the caller is). The redirect rewrites a shorthand change number URL (/123) to
+    // it's canonical long form (/c/project/+/123).
+    int changeId = createChange().getChange().getId().id;
+    RestResponse res = anonymousRestSession.get("/" + changeId);
+    res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
+  }
+
+  @Test
+  public void changeNumberRedirectsWithTrailingSlash() throws Exception {
+    int changeId = createChange().getChange().getId().id;
+    RestResponse res = anonymousRestSession.get("/" + changeId + "/");
+    res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
+  }
+
+  @Test
+  public void changeNumberOverflowNotFound() throws Exception {
+    RestResponse res = anonymousRestSession.get("/9" + Long.MAX_VALUE);
+    res.assertNotFound();
+  }
+
+  @Test
+  public void unknownChangeNumberNotFound() throws Exception {
+    RestResponse res = anonymousRestSession.get("/10");
+    res.assertNotFound();
+  }
+
+  @Test
+  public void hiddenChangeNotFound() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.id).setPrivate(true, null);
+    RestResponse res = anonymousRestSession.get("/" + changeId.id);
+    res.assertNotFound();
+  }
+
   private static String changeDetail(String changeId) {
     return "/changes/" + changeId + "/detail";
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 0af9708..6fd3bab 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -44,6 +45,7 @@
 import java.util.stream.Stream;
 import org.apache.http.Header;
 import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
 import org.apache.http.client.fluent.Executor;
 import org.apache.http.client.fluent.Request;
 import org.apache.http.cookie.Cookie;
@@ -99,6 +101,30 @@
   }
 
   @Test
+  public void originsOnNotFoundException() throws Exception {
+    String url = "/changes/999/detail";
+    check(url, true, "http://example.com", adminRestSession, 404);
+    check(url, false, "http://friendsly", adminRestSession, 404);
+  }
+
+  @Test
+  public void originsOnBadRequestException() throws Exception {
+    String url = "/config/server/caches/?format=NONSENSE";
+    check(url, true, "http://example.com", adminRestSession, 400);
+    check(url, false, "http://friendsly", adminRestSession, 400);
+  }
+
+  @Test
+  public void originsOnForbidden() throws Exception {
+    Result change = createChange();
+    // Make change private to hide it
+    gApi.changes().id(change.getChangeId()).setPrivate(true, "now private");
+    String url = "/changes/" + change.getChangeId() + "/detail";
+    check(url, true, "http://example.com", anonymousRestSession, 404);
+    check(url, false, "http://friendsly", anonymousRestSession, 404);
+  }
+
+  @Test
   public void putWithServerOriginAcceptedWithNoCorsResponseHeaders() throws Exception {
     Result change = createChange();
     String origin = adminRestSession.url();
@@ -247,9 +273,15 @@
   }
 
   private void check(String url, boolean accept, String origin) throws Exception {
+    check(url, accept, origin, adminRestSession, HttpStatus.SC_OK);
+  }
+
+  private void check(
+      String url, boolean accept, String origin, RestSession restSession, int httpStatusCode)
+      throws Exception {
     Header hdr = new BasicHeader(ORIGIN, origin);
-    RestResponse r = adminRestSession.getWithHeader(url, hdr);
-    r.assertOK();
+    RestResponse r = restSession.getWithHeader(url, hdr);
+    r.assertStatus(httpStatusCode);
     checkCors(r, accept, origin);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 206cc68..222973e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -332,6 +334,16 @@
     move(r.getChangeId(), null);
   }
 
+  @Test
+  @GerritConfig(name = "change.move", value = "false")
+  public void moveCanBeDisabledByConfig() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("move changes endpoint is disabled");
+    move(r.getChangeId(), null);
+  }
+
   private void move(int changeNum, String destination) throws RestApiException {
     gApi.changes().id(changeNum).move(destination);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index c1dda00..229122b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -24,10 +25,9 @@
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -657,7 +657,7 @@
   }
 
   @Test
-  public void dependencyOnHiddenChangeShouldPreventMergeButDoesnt() throws Exception {
+  public void dependencyOnHiddenChangePreventsMerge() throws Exception {
     grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, REGISTERED_USERS, false);
     grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
 
@@ -686,18 +686,80 @@
       assertThat(e.getMessage()).isEqualTo("Not found: " + changeResult.getChangeId());
     }
 
-    // Submit the second change which has a dependency on the first change which is not visible to
-    // the user. We would expect the submit to fail, but instead the submit succeeds and the hidden
-    // change gets submitted too.
-    // TODO(ekempin): Make this submit fail.
-    gApi.changes().id(change2Result.getChangeId()).current().submit(new SubmitInput());
+    // Submit is expected to fail.
+    try {
+      gApi.changes().id(change2Result.getChangeId()).current().submit();
+      fail("expected failure");
+    } catch (AuthException e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "A change to be submitted with "
+                  + change2Result.getChange().getId().id
+                  + " is not visible");
+    }
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 
-    // Verify that both changes have been submitted.
-    setApiUser(admin);
-    assertThat(gApi.changes().id(changeResult.getChangeId()).get().status)
-        .isEqualTo(ChangeStatus.MERGED);
-    assertThat(gApi.changes().id(change2Result.getChangeId()).get().status)
-        .isEqualTo(ChangeStatus.MERGED);
+  @Test
+  public void dependencyOnHiddenChangeUsingTopicPreventsMerge() throws Exception {
+    // Construct a topic where a change included by topic depends on a private change that is not
+    // visible to the submitting user
+    // (c1) --- topic --- (c2b)
+    //                      |
+    //                    (c2a) <= private
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    Project.NameKey p1 = createProject("project-where-we-submit");
+    Project.NameKey p2 = createProject("project-impacted-via-topic");
+
+    grantLabel("Code-Review", -2, 2, p1, "refs/heads/*", false, REGISTERED_USERS, false);
+    grant(p1, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    grantLabel("Code-Review", -2, 2, p2, "refs/heads/*", false, REGISTERED_USERS, false);
+    grant(p2, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+
+    TestRepository<?> repo1 = cloneProject(p1);
+    TestRepository<?> repo2 = cloneProject(p2);
+
+    PushOneCommit.Result change1 =
+        createChange(repo1, "master", "A fresh change in repo1", "a.txt", "1", "topic-to-submit");
+    approve(change1.getChangeId());
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), repo2, "An ancestor change in repo2", "a.txt", "2");
+    PushOneCommit.Result change2a = push.to("refs/for/master");
+    approve(change2a.getChangeId());
+    PushOneCommit.Result change2b =
+        createChange(
+            repo2, "master", "A topic-linked change in repo2", "a.txt", "2", "topic-to-submit");
+    approve(change2b.getChangeId());
+
+    // Mark change2a private so that it's not visible to user.
+    gApi.changes().id(change2a.getChangeId()).setPrivate(true, "nobody should see this");
+
+    setApiUser(user);
+
+    // Verify that user cannot see change2a
+    try {
+      gApi.changes().id(change2a.getChangeId()).get();
+      fail("expected failure");
+    } catch (ResourceNotFoundException e) {
+      assertThat(e.getMessage()).isEqualTo("Not found: " + change2a.getChangeId());
+    }
+
+    // Submit is expected to fail.
+    try {
+      gApi.changes().id(change1.getChangeId()).current().submit();
+      fail("expected failure");
+    } catch (AuthException e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "A change to be submitted with "
+                  + change1.getChange().getId().id
+                  + " is not visible");
+    }
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 8b1f4bc..0d40a1c 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -716,7 +716,7 @@
   }
 
   @Test
-  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+  public void queryChangesWithCommentCount() throws Exception {
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
     String changeId1 = result.getChangeId();
@@ -751,8 +751,11 @@
       ChangeInfo changeInfo2 = Iterables.getOnlyElement(query(changeId2));
       ChangeInfo changeInfo3 = Iterables.getOnlyElement(query(changeId3));
       assertThat(changeInfo1.unresolvedCommentCount).isEqualTo(2);
+      assertThat(changeInfo1.totalCommentCount).isEqualTo(4);
       assertThat(changeInfo2.unresolvedCommentCount).isEqualTo(0);
+      assertThat(changeInfo2.totalCommentCount).isEqualTo(2);
       assertThat(changeInfo3.unresolvedCommentCount).isEqualTo(1);
+      assertThat(changeInfo3.totalCommentCount).isEqualTo(2);
     } finally {
       enableDb(ctx);
     }
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 5a067f1..eb6f189 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -74,6 +74,7 @@
   @Inject private SchemaCreator schemaCreator;
   @Inject protected ThreadLocalRequestContext requestContext;
   @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ProjectConfig.Factory projectConfigFactory;
 
   private LifecycleManager lifecycle;
   private ReviewDb db;
@@ -198,7 +199,7 @@
 
   private ProjectConfig loadAllProjects() throws Exception {
     try (Repository repo = repoManager.openRepository(allProjects)) {
-      ProjectConfig pc = new ProjectConfig(allProjects);
+      ProjectConfig pc = projectConfigFactory.create(allProjects);
       pc.load(repo);
       return pc;
     }
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index 2edcf7c..3da35b2 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -19,7 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.client.Project;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -41,35 +41,35 @@
   @Test
   public void defaultSubmitTypeWhenNotConfigured() {
     // Check expected value explicitly rather than depending on constant.
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.INHERIT);
   }
 
   @Test
   public void defaultSubmitTypeForStarFilter() {
     configureDefaultSubmitType("*", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
     configureDefaultSubmitType("*", SubmitType.FAST_FORWARD_ONLY);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_IF_NECESSARY);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_ALWAYS);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_ALWAYS);
   }
 
   @Test
   public void defaultSubmitTypeForSpecificFilter() {
     configureDefaultSubmitType("someProject", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someOtherProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someOtherProject")))
         .isEqualTo(RepositoryConfig.DEFAULT_SUBMIT_TYPE);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
   }
 
@@ -79,13 +79,13 @@
     configureDefaultSubmitType("somePath/*", SubmitType.CHERRY_PICK);
     configureDefaultSubmitType("*", SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
         .isEqualTo(SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("somePath/someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("somePath/somePath/someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
   }
 
@@ -99,14 +99,14 @@
 
   @Test
   public void ownerGroupsWhenNotConfigured() {
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject"))).isEmpty();
   }
 
   @Test
   public void ownerGroupsForStarFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("*", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -114,8 +114,8 @@
   public void ownerGroupsForSpecificFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("someProject", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someOtherProject"))).isEmpty();
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someOtherProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -129,13 +129,13 @@
     configureOwnerGroups("somePath/*", ownerGroups2);
     configureOwnerGroups("somePath/somePath/*", ownerGroups3);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups1);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups2);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("somePath/somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups3);
   }
 
@@ -149,22 +149,24 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    assertThat(repoCfg.getBasePath(new NameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject"))).isNull();
   }
 
   @Test
   public void basePathForStarFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("*", basePath);
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
+        .isEqualTo(basePath);
   }
 
   @Test
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    assertThat(repoCfg.getBasePath(new NameKey("someOtherProject"))).isNull();
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(new Project.NameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
+        .isEqualTo(basePath);
   }
 
   @Test
@@ -179,12 +181,14 @@
     configureBasePath("project/*", basePath3);
     configureBasePath("*", basePath4);
 
-    assertThat(repoCfg.getBasePath(new NameKey("project1")).toString()).isEqualTo(basePath1);
-    assertThat(repoCfg.getBasePath(new NameKey("project/project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(new Project.NameKey("project1")).toString())
+        .isEqualTo(basePath1);
+    assertThat(repoCfg.getBasePath(new Project.NameKey("project/project/someProject")).toString())
         .isEqualTo(basePath2);
-    assertThat(repoCfg.getBasePath(new NameKey("project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(new Project.NameKey("project/someProject")).toString())
         .isEqualTo(basePath3);
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath4);
+    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
+        .isEqualTo(basePath4);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index a3f9f93..1d2d04c 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -206,6 +206,7 @@
   @Inject private DefaultRefFilter.Factory refFilterFactory;
   @Inject private TransferConfig transferConfig;
   @Inject private MetricMaker metricMaker;
+  @Inject private ProjectConfig.Factory projectConfigFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -274,7 +275,8 @@
 
     try {
       Repository repo = repoManager.createRepository(allProjectsName);
-      ProjectConfig allProjects = new ProjectConfig(new Project.NameKey(allProjectsName.get()));
+      ProjectConfig allProjects =
+          projectConfigFactory.create(new Project.NameKey(allProjectsName.get()));
       allProjects.load(repo);
       LabelType cr = Util.codeReview();
       allProjects.getLabelSections().put(cr.getName(), cr);
@@ -295,11 +297,11 @@
         CacheBuilder.newBuilder().build();
     sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c), metricMaker);
 
-    parent = new ProjectConfig(parentKey);
+    parent = projectConfigFactory.create(parentKey);
     parent.load(newRepository(parentKey));
     add(parent);
 
-    local = new ProjectConfig(localKey);
+    local = projectConfigFactory.create(localKey);
     local.load(newRepository(localKey));
     add(local);
     local.getProject().setParentName(parentKey);
@@ -455,7 +457,7 @@
     allow(local, READ, DEVS, "refs/heads/*");
     assertCanAccess(user(local, "a", ADMIN));
 
-    local = new ProjectConfig(localKey);
+    local = projectConfigFactory.create(localKey);
     local.load(newRepository(localKey));
     local.getProject().setParentName(parentKey);
     allow(local, READ, DEVS, "refs/*");
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index b39208a..2ae9e90 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -57,6 +57,7 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected AllProjectsName allProjects;
   @Inject private CommitsCollection commits;
+  @Inject private ProjectConfig.Factory projectConfigFactory;
 
   private TestRepository<InMemoryRepository> repo;
   private ProjectConfig project;
@@ -70,7 +71,7 @@
 
     Project.NameKey name = new Project.NameKey("project");
     InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
-    project = new ProjectConfig(name);
+    project = projectConfigFactory.create(name);
     project.load(inMemoryRepo);
     repo = new TestRepository<>(inMemoryRepo);
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 0e4ba10..764d49a 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -78,11 +78,13 @@
       new GroupReference(new AccountGroup.UUID("X"), "Developers");
   private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
 
+  private ProjectConfig.Factory factory;
   private Repository db;
   private TestRepository<?> tr;
 
   @Before
   public void setUp() throws Exception {
+    factory = new ProjectConfig.Factory();
     db = new InMemoryRepository(new DfsRepositoryDescription("repo"));
     tr = new TestRepository<>(db);
   }
@@ -104,6 +106,13 @@
                     + "  sameGroupVisibility = block group Staff\n"
                     + "[contributor-agreement \"Individual\"]\n"
                     + "  description = A simple description\n"
+                    + "  matchProjects = ^/ourproject\n"
+                    + "  matchProjects = ^/ourotherproject\n"
+                    + "  matchProjects = ^/someotherroot/ourproject\n"
+                    + "  excludeProjects = ^/theirproject\n"
+                    + "  excludeProjects = ^/theirotherproject\n"
+                    + "  excludeProjects = ^/someotherroot/theirproject\n"
+                    + "  excludeProjects = ^/someotherroot/theirotherproject\n"
                     + "  accepted = group Developers\n"
                     + "  accepted = group Staff\n"
                     + "  autoVerify = group Developers\n"
@@ -115,6 +124,14 @@
     ContributorAgreement ca = cfg.getContributorAgreement("Individual");
     assertThat(ca.getName()).isEqualTo("Individual");
     assertThat(ca.getDescription()).isEqualTo("A simple description");
+    assertThat(ca.getMatchProjectsRegexes())
+        .containsExactly("^/ourproject", "^/ourotherproject", "^/someotherroot/ourproject");
+    assertThat(ca.getExcludeProjectsRegexes())
+        .containsExactly(
+            "^/theirproject",
+            "^/theirotherproject",
+            "^/someotherroot/theirproject",
+            "^/someotherroot/theirotherproject");
     assertThat(ca.getAgreementUrl()).isEqualTo("http://www.example.com/agree");
     assertThat(ca.getAccepted()).hasSize(2);
     assertThat(ca.getAccepted().get(0).getGroup()).isEqualTo(developers);
@@ -256,6 +273,7 @@
                     + "  sameGroupVisibility = block group Staff\n"
                     + "[contributor-agreement \"Individual\"]\n"
                     + "  description = A simple description\n"
+                    + "  matchProjects = ^/ourproject\n"
                     + "  accepted = group Developers\n"
                     + "  autoVerify = group Developers\n"
                     + "  agreementUrl = http://www.example.com/agree\n"
@@ -273,6 +291,8 @@
     ContributorAgreement ca = cfg.getContributorAgreement("Individual");
     ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
     ca.setAutoVerify(null);
+    ca.setMatchProjectsRegexes(null);
+    ca.setExcludeProjectsRegexes(Collections.singletonList("^/theirproject"));
     ca.setDescription("A new description");
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
@@ -289,6 +309,7 @@
                 + "  description = A new description\n"
                 + "  accepted = group Staff\n"
                 + "  agreementUrl = http://www.example.com/agree\n"
+                + "\texcludeProjects = ^/theirproject\n"
                 + "[label \"CustomLabel\"]\n"
                 + LABEL_SCORES_CONFIG
                 + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
@@ -385,7 +406,7 @@
 
   @Test
   public void readUnexistingPluginConfig() throws Exception {
-    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
     cfg.load(db);
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).isEmpty();
@@ -573,7 +594,7 @@
   }
 
   private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
-    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
     cfg.load(db, rev);
     return cfg;
   }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index b9973e9..f362e5a 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -2378,6 +2378,12 @@
     cd.reviewers();
     cd.unresolvedCommentCount();
 
+    if (getSchemaVersion() < 51) {
+      assertMissingField(ChangeField.TOTAL_COMMENT_COUNT);
+    } else {
+      cd.totalCommentCount();
+    }
+
     // TODO(dborowitz): Swap out GitRepositoryManager somehow? Will probably be
     // necessary for NoteDb anyway.
     cd.isMergeable();
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
index d3f69982..9cb9333 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -50,6 +50,8 @@
 
   @Inject private InMemoryDatabase db;
 
+  @Inject private ProjectConfig.Factory projectConfigFactory;
+
   @Before
   public void setUp() throws Exception {
     new InMemoryModule().inject(this);
@@ -85,7 +87,7 @@
 
   private LabelTypes getLabelTypes() throws Exception {
     db.create();
-    ProjectConfig c = new ProjectConfig(allProjects);
+    ProjectConfig c = projectConfigFactory.create(allProjects);
     try (Repository repo = repoManager.openRepository(allProjects)) {
       c.load(repo);
       return new LabelTypes(ImmutableList.copyOf(c.getLabelSections().values()));
diff --git a/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java b/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
index 67d071d..10aabe8 100644
--- a/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
+++ b/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
@@ -47,6 +47,7 @@
   @Inject private GitRepositoryManager repoManager;
   @Inject private Schema_162 schema162;
   @Inject private ReviewDb db;
+  @Inject private ProjectConfig.Factory projectConfigFactory;
   @Inject @GerritPersonIdent private PersonIdent serverUser;
 
   @Test
@@ -73,7 +74,7 @@
 
     try (Repository git = repoManager.openRepository(allUsersName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig cfg = ProjectConfig.read(md);
+      ProjectConfig cfg = projectConfigFactory.read(md);
       cfg.getProject().setParentName(testProject);
       md.getCommitBuilder().setCommitter(serverUser);
       md.getCommitBuilder().setAuthor(serverUser);
diff --git a/lib/BUILD b/lib/BUILD
index e5034c9..95ca4db 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -90,8 +90,10 @@
     name = "guava",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@guava//jar"],
-    runtime_deps = [":j2objc"],
+    exports = [
+        ":j2objc",
+        "@guava//jar",
+    ],
 )
 
 java_library(
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index f2117a6..de254be 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,6 +1,6 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_CENTRAL", "MAVEN_LOCAL", "maven_jar")
 
-_JGIT_VERS = "5.1.2.201810061102-r"
+_JGIT_VERS = "5.1.3.201810200350-r"
 
 _DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
@@ -40,28 +40,28 @@
         name = "jgit-lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "467c951f20aef345c584e1d578be691ac7ae6fbc",
-        src_sha1 = "37a8b0233413af35886be512ebfcd499a439d455",
+        sha1 = "f270dbd1d792d5ad06074abe018a18644c90b60e",
+        src_sha1 = "00e24ee2b721040edbb8520d705607a7f7bafd64",
         unsign = True,
     )
     maven_jar(
         name = "jgit-servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f8a7f7934b8038fe01f26a0908b648385dbc5ffe",
+        sha1 = "360405244c28b537f0eafdc0b9d9f3753503d981",
         unsign = True,
     )
     maven_jar(
         name = "jgit-archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "c51089a2e1f225f4b10e78e9bfc9c077a9337977",
+        sha1 = "08e10921fcc75ead2736dd5bf099ba8e2ed8a3fb",
     )
     maven_jar(
         name = "jgit-junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "afd35253f780ffb64281bcb3abfe24cceef78d2e",
+        sha1 = "1dc8f86bba3c461cb90c9dc3e91bf343889ca684",
         unsign = True,
     )
 
diff --git a/plugins/replication b/plugins/replication
index 092792e..a7b900b 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 092792edacf9c29732a560a30967b92664cd65f9
+Subproject commit a7b900bd524c333c8ca2825e37fa781ac055ac36
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 60eaf1f..0a685da 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -14,6 +14,88 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
+
+<!--
+
+How to Add a Keyboard Shortcut
+==============================
+
+A keyboard shortcut is composed of the following parts:
+
+  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
+  2. Documentation for the keyboard shortcut help dialog
+  3. A binding between key combos and the semantic identifier
+  4. A binding between the semantic identifier and a listener
+
+Parts (1) and (2) for all shortcuts are defined in this file. The semantic
+identifier is declared in the Shortcut enum near the head of this script:
+
+  const Shortcut = {
+    // ...
+    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+    // ...
+  };
+
+Immediately following the Shortcut enum definition, there is a _describe
+function defined which is then invoked many times to populate the help dialog.
+Add a new invocation here to document the shortcut:
+
+  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+      'Hide/show left diff');
+
+When an attached view binds one or more key combos to this shortcut, the help
+dialog will display this text in the given section (in this case, "Diffs"). See
+the ShortcutSection enum immediately below for the list of supported sections.
+
+Part (3), the actual key bindings, are declared by gr-app. In the future, this
+system may be expanded to allow key binding customizations by plugins or user
+preferences. Key bindings are defined in the following forms:
+
+  // Ordinary shortcut with a single binding.
+  this.bindShortcut(
+      this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
+  // Ordinary shortcut with multiple bindings.
+  this.bindShortcut(
+      this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+
+  // A "go-key" keyboard shortcut, which is combined with a previously and
+  // continuously pressed "go" key (the go-key is hard-coded as 'g').
+  this.bindShortcut(
+      this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+
+  // A "doc-only" keyboard shortcut. This declares the key-binding for help
+  // dialog purposes, but doesn't actually implement the binding. It is up
+  // to some element to implement this binding using iron-a11y-keys-behavior's
+  // keyBindings property.
+  this.bindShortcut(
+      this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+
+Part (4), the listener definitions, are declared by the view or element that
+implements the shortcut behavior. This is done by implementing a method named
+keyboardShortcuts() in an element that mixes in this behavior, returning an
+object that maps semantic identifiers (as property names) to listener method
+names, like this:
+
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+    };
+  },
+
+You can implement key bindings in an element that is hosted by a view IF that
+element is always attached exactly once under that view (e.g. the search bar in
+gr-app). When that is not the case, you will have to define a doc-only binding
+in gr-app, declare the shortcut in the view that hosts the element, and use
+iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
+element. An example of this is in comment threads. A diff view supports actions
+on comment threads, but there may be zero or many comment threads attached at
+any given point. So the shortcut is declared as doc-only by the diff view and
+by gr-app, and actually implemented by gr-diff-comment-thread.
+
+NOTE: doc-only shortcuts will not be customizable in the same way that other
+shortcuts are.
+-->
 <link rel="import" href="../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 
@@ -21,10 +103,188 @@
 (function(window) {
   'use strict';
 
+  const DOC_ONLY = 'DOC_ONLY';
+  const GO_KEY = 'GO_KEY';
+
+  // The maximum age of a keydown event to be used in a jump navigation. This
+  // is only for cases when the keyup event is lost.
+  const GO_KEY_TIMEOUT_MS = 1000;
+
+  const ShortcutSection = {
+    ACTIONS: 'Actions',
+    DIFFS: 'Diffs',
+    EVERYWHERE: 'Everywhere',
+    FILE_LIST: 'File list',
+    NAVIGATION: 'Navigation',
+    REPLY_DIALOG: 'Reply dialog',
+  };
+
+  const Shortcut = {
+    OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
+    GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
+    GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
+    GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
+
+    CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
+    CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
+    OPEN_CHANGE: 'OPEN_CHANGE',
+    NEXT_PAGE: 'NEXT_PAGE',
+    PREV_PAGE: 'PREV_PAGE',
+    TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
+    TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
+    REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
+
+    OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
+    OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
+    EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
+    COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
+    UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
+    UP_TO_CHANGE: 'UP_TO_CHANGE',
+    TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
+    REFRESH_CHANGE: 'REFRESH_CHANGE',
+
+    NEXT_LINE: 'NEXT_LINE',
+    PREV_LINE: 'PREV_LINE',
+    NEXT_CHUNK: 'NEXT_CHUNK',
+    PREV_CHUNK: 'PREV_CHUNK',
+    EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
+    NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
+    PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
+    EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
+    COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
+    LEFT_PANE: 'LEFT_PANE',
+    RIGHT_PANE: 'RIGHT_PANE',
+    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+    NEW_COMMENT: 'NEW_COMMENT',
+    SAVE_COMMENT: 'SAVE_COMMENT',
+    OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
+    TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
+
+    NEXT_FILE: 'NEXT_FILE',
+    PREV_FILE: 'PREV_FILE',
+    NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
+    PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
+    CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
+    CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
+    OPEN_FILE: 'OPEN_FILE',
+    TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
+    TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
+    TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
+
+    OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
+    OPEN_LAST_FILE: 'OPEN_LAST_FILE',
+
+    SEARCH: 'SEARCH',
+    SEND_REPLY: 'SEND_REPLY',
+  };
+
+  const _help = new Map();
+
+  function _describe(shortcut, section, text) {
+    if (!_help.has(section)) {
+      _help.set(section, []);
+    }
+    _help.get(section).push({shortcut, text});
+  }
+
+  _describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
+  _describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
+      'Show this dialog');
+  _describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
+      'Go to Opened Changes');
+  _describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
+      'Go to Merged Changes');
+  _describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
+      'Go to Abandoned Changes');
+
+  _describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
+      'Select next change');
+  _describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
+      'Select previous change');
+  _describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
+      'Show selected change');
+  _describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
+  _describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
+  _describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
+      'Open reply dialog to publish comments and add reviewers');
+  _describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
+      'Open download overlay');
+  _describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
+      'Expand all messages');
+  _describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
+      'Collapse all messages');
+  _describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
+      'Reload the change at the latest patch');
+  _describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
+      'Mark/unmark change as reviewed');
+  _describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
+      'Toggle review flag on selected file');
+  _describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
+      'Refresh list of changes');
+  _describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
+      'Star/unstar change');
+
+  _describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+  _describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+  _describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
+      'Go to next diff chunk');
+  _describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
+      'Go to previous diff chunk');
+  _describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
+      'Expand all diff context');
+  _describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
+      'Go to next comment thread');
+  _describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
+      'Go to previous comment thread');
+  _describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+      'Expand all comment threads');
+  _describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+      'Collapse all comment threads');
+  _describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
+  _describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
+  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+      'Hide/show left diff');
+  _describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
+  _describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
+  _describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
+      'Show diff preferences');
+  _describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
+      'Mark/unmark file as reviewed');
+  _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
+      'Toggle unified/side-by-side diff');
+
+  _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
+  _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
+      'Select previous file');
+  _describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
+      'Select next file that has comments');
+  _describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
+      'Select previous file that has comments');
+  _describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
+      'Show first file');
+  _describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
+      'Show last file');
+  _describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
+      'Up to dashboard');
+  _describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
+
+  _describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
+      'Select next file');
+  _describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
+      'Select previous file');
+  _describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
+      'Go to selected file');
+  _describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
+      'Show/hide all inline diffs');
+  _describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
+      'Show/hide selected inline diff');
+
+  _describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+
   // Must be declared outside behavior implementation to be accessed inside
   // behavior functions.
 
-  /** @return {!Object} */
+  /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
   const getKeyboardEvent = function(e) {
     e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
     // When e is a keyboardEvent, e.event is not null.
@@ -32,44 +292,307 @@
     return e;
   };
 
+  class ShortcutManager {
+    constructor() {
+      this.activeHosts = new Map();
+      this.bindings = new Map();
+      this.listeners = new Set();
+    }
+
+    bindShortcut(shortcut, ...bindings) {
+      this.bindings.set(shortcut, bindings);
+    }
+
+    getBindingsForShortcut(shortcut) {
+      return this.bindings.get(shortcut);
+    }
+
+    attachHost(host) {
+      if (!host.keyboardShortcuts) { return; }
+      const shortcuts = host.keyboardShortcuts();
+      this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+      this.notifyListeners();
+      return shortcuts;
+    }
+
+    detachHost(host) {
+      if (this.activeHosts.delete(host)) {
+        this.notifyListeners();
+        return true;
+      }
+      return false;
+    }
+
+    addListener(listener) {
+      this.listeners.add(listener);
+      listener(this.directoryView());
+    }
+
+    removeListener(listener) {
+      return this.listeners.delete(listener);
+    }
+
+    activeShortcutsBySection() {
+      const activeShortcuts = new Set();
+      this.activeHosts.forEach(shortcuts => {
+        shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+      });
+
+      const activeShortcutsBySection = new Map();
+      _help.forEach((shortcutList, section) => {
+        shortcutList.forEach(shortcutHelp => {
+          if (activeShortcuts.has(shortcutHelp.shortcut)) {
+            if (!activeShortcutsBySection.has(section)) {
+              activeShortcutsBySection.set(section, []);
+            }
+            activeShortcutsBySection.get(section).push(shortcutHelp);
+          }
+        });
+      });
+      return activeShortcutsBySection;
+    }
+
+    directoryView() {
+      const view = new Map();
+      this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+        const sectionView = [];
+        shortcutHelps.forEach(shortcutHelp => {
+          const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+          if (!bindingDesc) { return; }
+          this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+            sectionView.push({
+              binding: bindingDesc,
+              text: shortcutHelp.text,
+            });
+          });
+        });
+        view.set(section, sectionView);
+      });
+      return view;
+    }
+
+    distributeBindingDesc(bindingDesc) {
+      if (bindingDesc.length === 1 ||
+          this.comboSetDisplayWidth(bindingDesc) < 21) {
+        return [bindingDesc];
+      }
+      // Find the largest prefix of bindings that is under the
+      // size threshold.
+      const head = [bindingDesc[0]];
+      for (let i = 1; i < bindingDesc.length; i++) {
+        head.push(bindingDesc[i]);
+        if (this.comboSetDisplayWidth(head) >= 21) {
+          head.pop();
+          return [head].concat(
+              this.distributeBindingDesc(bindingDesc.slice(i)));
+        }
+      }
+    }
+
+    comboSetDisplayWidth(bindingDesc) {
+      const bindingSizer = binding => binding.reduce(
+          (acc, key) => acc + key.length, 0);
+      // Width is the sum of strings + (n-1) * 2 to account for the word
+      // "or" joining them.
+      return bindingDesc.reduce(
+          (acc, binding) => acc + bindingSizer(binding), 0) +
+          2 * (bindingDesc.length - 1);
+    }
+
+    describeBindings(shortcut) {
+      const bindings = this.bindings.get(shortcut);
+      if (!bindings) { return null; }
+      if (bindings[0] === GO_KEY) {
+        return [['g'].concat(bindings.slice(1))];
+      }
+      return bindings
+          .filter(binding => binding !== DOC_ONLY)
+          .map(binding => this.describeBinding(binding));
+    }
+
+    describeBinding(binding) {
+      return binding.split(':')[0].split('+').map(part => {
+        switch (part) {
+          case 'shift':
+            return 'Shift';
+          case 'meta':
+            return 'Meta';
+          case 'ctrl':
+            return 'Ctrl';
+          case 'enter':
+            return 'Enter';
+          case 'up':
+            return '↑';
+          case 'down':
+            return '↓';
+          case 'left':
+            return '←';
+          case 'right':
+            return '→';
+          default:
+            return part;
+        }
+      });
+    }
+
+    notifyListeners() {
+      const view = this.directoryView();
+      this.listeners.forEach(listener => listener(view));
+    }
+  }
+
+  const shortcutManager = new ShortcutManager();
+
   window.Gerrit = window.Gerrit || {};
 
   /** @polymerBehavior KeyboardShortcutBehavior */
-  Gerrit.KeyboardShortcutBehavior = [{
-    modifierPressed(e) {
-      e = getKeyboardEvent(e);
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
-    },
-
-    isModifierPressed(e, modifier) {
-      return getKeyboardEvent(e)[modifier];
-    },
-
-    shouldSuppressKeyboardShortcut(e) {
-      e = getKeyboardEvent(e);
-      const tagName = Polymer.dom(e).rootTarget.tagName;
-      if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
-          (e.keyCode === 13 && tagName === 'A')) {
-        // Suppress shortcuts if the key is 'enter' and target is an anchor.
-        return true;
-      }
-      for (let i = 0; e.path && i < e.path.length; i++) {
-        if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
-      }
-      return false;
-    },
-
-    // Alias for getKeyboardEvent.
-    /** @return {!Object} */
-    getKeyboardEvent(e) {
-      return getKeyboardEvent(e);
-    },
-
-    getRootTarget(e) {
-      return Polymer.dom(getKeyboardEvent(e)).rootTarget;
-    },
-  },
+  Gerrit.KeyboardShortcutBehavior = [
     Polymer.IronA11yKeysBehavior,
+    {
+      // Exports for convenience. Note: Closure compiler crashes when
+      // object-shorthand syntax is used here.
+      // eslint-disable-next-line object-shorthand
+      DOC_ONLY: DOC_ONLY,
+      // eslint-disable-next-line object-shorthand
+      GO_KEY: GO_KEY,
+      // eslint-disable-next-line object-shorthand
+      Shortcut: Shortcut,
+
+      properties: {
+        _shortcut_go_key_last_pressed: {
+          type: Number,
+          value: null,
+        },
+
+        _shortcut_go_table: {
+          type: Array,
+          value() { return new Map(); },
+        },
+      },
+
+      modifierPressed(e) {
+        e = getKeyboardEvent(e);
+        return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+      },
+
+      isModifierPressed(e, modifier) {
+        return getKeyboardEvent(e)[modifier];
+      },
+
+      shouldSuppressKeyboardShortcut(e) {
+        e = getKeyboardEvent(e);
+        const tagName = Polymer.dom(e).rootTarget.tagName;
+        if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
+            (e.keyCode === 13 && tagName === 'A')) {
+          // Suppress shortcuts if the key is 'enter' and target is an anchor.
+          return true;
+        }
+        for (let i = 0; e.path && i < e.path.length; i++) {
+          if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
+        }
+        return false;
+      },
+
+      // Alias for getKeyboardEvent.
+      /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
+      getKeyboardEvent(e) {
+        return getKeyboardEvent(e);
+      },
+
+      getRootTarget(e) {
+        return Polymer.dom(getKeyboardEvent(e)).rootTarget;
+      },
+
+      bindShortcut(shortcut, ...bindings) {
+        shortcutManager.bindShortcut(shortcut, ...bindings);
+      },
+
+      _addOwnKeyBindings(shortcut, handler) {
+        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+        if (!bindings) {
+          return;
+        }
+        if (bindings[0] === DOC_ONLY) {
+          return;
+        }
+        if (bindings[0] === GO_KEY) {
+          this._shortcut_go_table.set(bindings[1], handler);
+        } else {
+          this.addOwnKeyBinding(bindings.join(' '), handler);
+        }
+      },
+
+      attached() {
+        const shortcuts = shortcutManager.attachHost(this);
+        if (!shortcuts) { return; }
+
+        for (const key of Object.keys(shortcuts)) {
+          this._addOwnKeyBindings(key, shortcuts[key]);
+        }
+
+        // If any of the shortcuts utilized GO_KEY, then they are handled
+        // directly by this behavior.
+        if (this._shortcut_go_table.size > 0) {
+          this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+          this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+          this._shortcut_go_table.forEach((handler, key) => {
+            this.addOwnKeyBinding(key, '_handleGoAction');
+          });
+        }
+      },
+
+      detached() {
+        if (shortcutManager.detachHost(this)) {
+          this.removeOwnKeyBindings();
+        }
+      },
+
+      keyboardShortcuts() {
+        return {};
+      },
+
+      addKeyboardShortcutDirectoryListener(listener) {
+        shortcutManager.addListener(listener);
+      },
+
+      removeKeyboardShortcutDirectoryListener(listener) {
+        shortcutManager.removeListener(listener);
+      },
+
+      _handleGoKeyDown(e) {
+        if (this.modifierPressed(e)) { return; }
+        this._shortcut_go_key_last_pressed = Date.now();
+      },
+
+      _handleGoKeyUp(e) {
+        this._shortcut_go_key_last_pressed = null;
+      },
+
+      _handleGoAction(e) {
+        if (!this._shortcut_go_key_last_pressed ||
+            (Date.now() - this._shortcut_go_key_last_pressed >
+                GO_KEY_TIMEOUT_MS) ||
+            !this._shortcut_go_table.has(e.detail.key) ||
+            this.shouldSuppressKeyboardShortcut(e)) {
+          return;
+        }
+        e.preventDefault();
+        const handler = this._shortcut_go_table.get(e.detail.key);
+        this[handler](e);
+      },
+    },
   ];
+
+  Gerrit.KeyboardShortcutBinder = {
+    DOC_ONLY,
+    GO_KEY,
+    Shortcut,
+    ShortcutManager,
+    ShortcutSection,
+
+    bindShortcut(shortcut, ...bindings) {
+      shortcutManager.bindShortcut(shortcut, ...bindings);
+    },
+  };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index 04193dd..dac90f8 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -40,6 +40,8 @@
 
 <script>
   suite('keyboard-shortcut-behavior tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+
     let element;
     let overlay;
     let sandbox;
@@ -67,6 +69,228 @@
       sandbox.restore();
     });
 
+    suite('ShortcutManager', () => {
+      test('bindings management', () => {
+        const mgr = new kb.ShortcutManager();
+        const {NEXT_FILE} = kb.Shortcut;
+
+        assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+        mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+        assert.deepEqual(
+            mgr.getBindingsForShortcut(NEXT_FILE),
+            [']', '}', 'right']);
+      });
+
+      suite('binding descriptions', () => {
+        function mapToObject(m) {
+          const o = {};
+          m.forEach((v, k) => o[k] = v);
+          return o;
+        }
+
+        test('single combo description', () => {
+          const mgr = new kb.ShortcutManager();
+          assert.deepEqual(mgr.describeBinding('a'), ['a']);
+          assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+          assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
+          assert.deepEqual(
+              mgr.describeBinding('ctrl+shift+up:keyup'),
+              ['Ctrl', 'Shift', '↑']);
+        });
+
+        test('combo set description', () => {
+          const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
+          const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
+
+          const mgr = new ShortcutManager();
+          assert.isNull(mgr.describeBindings(NEXT_FILE));
+
+          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+          assert.deepEqual(
+              mgr.describeBindings(GO_TO_OPENED_CHANGES),
+              [['g', 'o']]);
+
+          mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
+          assert.deepEqual(
+              mgr.describeBindings(NEXT_FILE),
+              [[']'], ['Ctrl', 'Shift', '→']]);
+
+          mgr.bindShortcut(PREV_FILE, '[');
+          assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
+        });
+
+        test('combo set description width', () => {
+          const mgr = new kb.ShortcutManager();
+          assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+          assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+          assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+          assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+          assert.strictEqual(
+              mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+              12);
+        });
+
+        test('distribute shortcut help', () => {
+          const mgr = new kb.ShortcutManager();
+          assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([['g', 'o']]),
+              [[['g', 'o']]]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+              [[['ctrl', 'shift', 'meta', 'enter']]]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([
+                ['ctrl', 'shift', 'meta', 'enter'],
+                ['o'],
+              ]),
+              [
+                [['ctrl', 'shift', 'meta', 'enter']],
+                [['o']],
+              ]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([
+                ['ctrl', 'enter'],
+                ['meta', 'enter'],
+                ['ctrl', 's'],
+                ['meta', 's'],
+              ]),
+              [
+                [['ctrl', 'enter'], ['meta', 'enter']],
+                [['ctrl', 's'], ['meta', 's']],
+              ]);
+        });
+
+        test('active shortcuts by section', () => {
+          const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
+              kb.Shortcut;
+          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+
+          const mgr = new kb.ShortcutManager();
+          mgr.bindShortcut(NEXT_FILE, ']');
+          mgr.bindShortcut(NEXT_LINE, 'j');
+          mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
+          mgr.bindShortcut(SEARCH, '/');
+
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {});
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [NEXT_FILE]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {
+                [NAVIGATION]: [
+                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                ],
+              });
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [NEXT_LINE]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {
+                [DIFFS]: [
+                  {shortcut: NEXT_LINE, text: 'Go to next line'},
+                ],
+                [NAVIGATION]: [
+                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                ],
+              });
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [SEARCH]: null,
+                [GO_TO_OPENED_CHANGES]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {
+                [DIFFS]: [
+                  {shortcut: NEXT_LINE, text: 'Go to next line'},
+                ],
+                [EVERYWHERE]: [
+                  {shortcut: SEARCH, text: 'Search'},
+                  {
+                    shortcut: GO_TO_OPENED_CHANGES,
+                    text: 'Go to Opened Changes',
+                  },
+                ],
+                [NAVIGATION]: [
+                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                ],
+              });
+        });
+
+        test('directory view', () => {
+          const {
+              NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
+              SAVE_COMMENT,
+          } = kb.Shortcut;
+          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+          const {GO_KEY, ShortcutManager} = kb;
+
+          const mgr = new ShortcutManager();
+          mgr.bindShortcut(NEXT_FILE, ']');
+          mgr.bindShortcut(NEXT_LINE, 'j');
+          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+          mgr.bindShortcut(SEARCH, '/');
+          mgr.bindShortcut(
+              SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+
+          assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [GO_TO_OPENED_CHANGES]: null,
+                [NEXT_FILE]: null,
+                [NEXT_LINE]: null,
+                [SAVE_COMMENT]: null,
+                [SEARCH]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.directoryView()),
+              {
+                [DIFFS]: [
+                  {binding: [['j']], text: 'Go to next line'},
+                  {
+                    binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
+                    text: 'Save comment',
+                  },
+                  {
+                    binding: [['Ctrl', 's'], ['Meta', 's']],
+                    text: 'Save comment',
+                  },
+                ],
+                [EVERYWHERE]: [
+                  {binding: [['/']], text: 'Search'},
+                  {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+                ],
+                [NAVIGATION]: [
+                  {binding: [[']']], text: 'Select next file'},
+                ],
+              });
+        });
+      });
+    });
+
     test('doesn’t block kb shortcuts for non-whitelisted els', done => {
       const divEl = document.createElement('div');
       element.appendChild(divEl);
@@ -160,5 +384,56 @@
       MockInteractions.keyDownOn(element, 75, 'alt', 'k');
       assert.isFalse(spy.lastCall.returnValue);
     });
+
+    suite('GO_KEY timing', () => {
+      let handlerStub;
+
+      setup(() => {
+        element._shortcut_go_table.set('a', '_handleA');
+        handlerStub = element._handleA = sinon.stub();
+        sandbox.stub(Date, 'now').returns(10000);
+      });
+
+      test('success', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = 9000;
+        element._handleGoAction(e);
+        assert.isTrue(handlerStub.calledOnce);
+        assert.strictEqual(handlerStub.lastCall.args[0], e);
+      });
+
+      test('go key not pressed', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = null;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+
+      test('go key pressed too long ago', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = 3000;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+
+      test('should suppress', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+        element._shortcut_go_key_last_pressed = 9000;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+
+      test('unrecognized key', () => {
+        const e = {detail: {key: 'f'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = 9000;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index 4be674f..2cb00f4 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -110,8 +110,9 @@
     /**
      *  @return {string}
      */
-    changeBaseURL(changeNum, patchNum) {
-      let v = this.getBaseUrl() + '/changes/' + changeNum;
+    changeBaseURL(project, changeNum, patchNum) {
+      let v = this.getBaseUrl() + '/changes/' +
+         encodeURIComponent(project) + '~' + changeNum;
       if (patchNum) {
         v += '/revisions/' + patchNum;
       }
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index d3ce73c..49d90f0 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -68,8 +68,8 @@
 
     test('changeBaseURL', () => {
       assert.deepEqual(
-          element.changeBaseURL('1', '1'),
-          '/r/changes/1/revisions/1'
+          element.changeBaseURL('test/project', '1', '2'),
+          '/r/changes/test%2Fproject~1/revisions/2'
       );
     });
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 372e6be..2235ae1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -55,14 +55,14 @@
       </tr>
       <template is="dom-repeat" items="[[sections]]" as="changeSection"
           index-as="sectionIndex">
-        <template is="dom-if" if="[[changeSection.sectionName]]">
+        <template is="dom-if" if="[[changeSection.name]]">
           <tr class="groupHeader">
             <td class="leftPadding"></td>
             <td class="star" hidden$="[[!showStar]]" hidden></td>
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
               <a href$="[[_sectionHref(changeSection.query)]]">
-                [[changeSection.sectionName]]
+                [[changeSection.name]]
               </a>
             </td>
           </tr>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index ba768d9..708a730 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -63,7 +63,7 @@
        * properties should not be used together.
        *
        * @type {!Array<{
-       *   sectionName: string,
+       *   name: string,
        *   query: string,
        *   results: !Array<!Object>
        * }>}
@@ -106,17 +106,6 @@
       Gerrit.URLEncodingBehavior,
     ],
 
-    keyBindings: {
-      'j': '_handleJKey',
-      'k': '_handleKKey',
-      'n ]': '_handleNKey',
-      'o': '_handleOKey',
-      'p [': '_handlePKey',
-      'r': '_handleRKey',
-      'shift+r': '_handleShiftRKey',
-      's': '_handleSKey',
-    },
-
     listeners: {
       keydown: '_scopedKeydownHandler',
     },
@@ -126,6 +115,19 @@
       '_computePreferences(account, preferences)',
     ],
 
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+        [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+        [this.Shortcut.NEXT_PAGE]: '_nextPage',
+        [this.Shortcut.PREV_PAGE]: '_prevPage',
+        [this.Shortcut.OPEN_CHANGE]: '_openChange',
+        [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+        [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+      };
+    },
+
     /**
      * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
      * events must be scoped to a component level (e.g. `enter`) in order to not
@@ -136,7 +138,7 @@
     _scopedKeydownHandler(e) {
       if (e.keyCode === 13) {
         // Enter.
-        this._handleOKey(e);
+        this._openChange(e);
       }
     },
 
@@ -238,7 +240,7 @@
       return account._account_id === change.assignee._account_id;
     },
 
-    _handleJKey(e) {
+    _nextChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -246,7 +248,7 @@
       this.$.cursor.next();
     },
 
-    _handleKKey(e) {
+    _prevChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -254,7 +256,7 @@
       this.$.cursor.previous();
     },
 
-    _handleOKey(e) {
+    _openChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -262,7 +264,7 @@
       Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
     },
 
-    _handleNKey(e) {
+    _nextPage(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
         return;
@@ -272,7 +274,7 @@
       this.fire('next-page');
     },
 
-    _handlePKey(e) {
+    _prevPage(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
         return;
@@ -282,7 +284,7 @@
       this.fire('previous-page');
     },
 
-    _handleRKey(e) {
+    _toggleChangeReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -300,7 +302,7 @@
       changeEl.toggleReviewed();
     },
 
-    _handleShiftRKey(e) {
+    _refreshChangeList(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -311,7 +313,7 @@
       window.location.reload();
     },
 
-    _handleSKey(e) {
+    _toggleChangeStar(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index bb904b5..d20d40a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -42,6 +42,17 @@
 
 <script>
   suite('gr-change-list basic tests', () => {
+    // Define keybindings before attaching other fixtures.
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
+    kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
+    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
+    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
+
     let element;
     let sandbox;
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
index 68187c6..e793f42 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
@@ -51,6 +51,7 @@
         text-align: center;
       }
       #help {
+        padding-top: 1.35em;
         vertical-align: top;
       }
       #help h1 {
@@ -74,7 +75,7 @@
       </p>
     </div>
     <div id="help">
-      <h1>Push your first changes for code review</h1>
+      <h1>Push your first change for code review</h1>
       <p>
         Pushing a change for review is easy, but a little different from
         other git code review tools. Click on the `Create Change' button
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index 2bc12c6..99aa265 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -101,6 +101,9 @@
           <template is="dom-if" if="[[_showNewUserHelp]]">
             <gr-create-change-help on-create-tap="createChangeTap"></gr-create-change-help>
           </template>
+          <template is="dom-if" if="[[!_showNewUserHelp]]">
+            No changes
+          </template>
         </div>
       </gr-change-list>
     </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 775c046..3fc4089 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -207,7 +207,7 @@
               this._showNewUserHelp = lastResultSet.length == 0;
             }
             this._results = changes.map((results, i) => ({
-              sectionName: res.sections[i].name,
+              name: res.sections[i].name,
               query: res.sections[i].query,
               results,
               isOutgoing: res.sections[i].isOutgoing,
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 17484b0..78fdee6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -279,7 +279,7 @@
 
       return element._fetchDashboardChanges({sections}, false).then(() => {
         assert.equal(element._results.length, 1);
-        assert.equal(element._results[0].sectionName, 'test2');
+        assert.equal(element._results[0].name, 'test2');
       });
     });
 
@@ -299,6 +299,20 @@
       });
     });
 
+    test('_showNewUserHelp', () => {
+      element._loading = false;
+      element._showNewUserHelp = false;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+      assert.isNotOk(element.$$('gr-create-change-help'));
+      element._showNewUserHelp = true;
+      flushAsynchronousOperations();
+
+      assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+      assert.isOk(element.$$('gr-create-change-help'));
+    });
+
     test('_computeUserHeaderClass', () => {
       assert.equal(element._computeUserHeaderClass(undefined), '');
       assert.equal(element._computeUserHeaderClass(''), '');
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
new file mode 100644
index 0000000..2394e24
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
@@ -0,0 +1,41 @@
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
+<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
+
+<dom-module id="gr-embed-dashboard">
+  <template>
+    <gr-change-list
+        show-star
+        account="[[account]]"
+        preferences="[[preferences]]"
+        sections="[[sections]]">
+      <div id="emptyOutgoing" slot="empty-outgoing">
+        <template is="dom-if" if="[[showNewUserHelp]]">
+          <gr-create-change-help></gr-create-change-help>
+        </template>
+        <template is="dom-if" if="[[!showNewUserHelp]]">
+          No changes
+        </template>
+      </div>
+    </gr-change-list>
+  </template>
+  <script src="gr-embed-dashboard.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
new file mode 100644
index 0000000..e31f9ad
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-embed-dashboard',
+    properties: {
+      account: Object,
+      sections: Array,
+      preferences: Object,
+      showNewUserHelp: Boolean,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 4506c16..148cd34 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -587,6 +587,7 @@
             change-comments="[[_changeComments]]"
             project-name="[[_change.project]]"
             show-reply-buttons="[[_loggedIn]]"
+            on-message-anchor-tap="_handleMessageAnchorTap"
             on-reply="_handleMessageReply"></gr-messages-list>
       </template>
       <template is="dom-if" if="[[!_showMessagesView]]">
@@ -608,6 +609,7 @@
     </gr-overlay>
     <gr-overlay id="uploadHelpOverlay" with-backdrop>
       <gr-upload-help-dialog
+          revision="[[_currentRevision]]"
           target-branch="[[_change.branch]]"
           on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
     </gr-overlay>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 257a062..a905c9f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -44,6 +44,8 @@
 
   const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
+  const MSG_PREFIX = '#message-';
+
   const ReloadToastMessage = {
     NEWER_REVISION: 'A newer patch set has been uploaded',
     RESTORED: 'This change has been restored',
@@ -136,6 +138,11 @@
       },
       /** @type {?} */
       _commitInfo: Object,
+      _currentRevision: {
+        type: Object,
+        computed: '_computeCurrentRevision(_change.current_revision, ' +
+            '_change.revisions)',
+      },
       _files: Object,
       _changeNum: String,
       _diffDrafts: {
@@ -263,16 +270,20 @@
       '_patchNumChanged(_patchRange.patchNum)',
     ],
 
-    keyBindings: {
-      'shift+r': '_handleCapitalRKey',
-      'a': '_handleAKey',
-      'd': '_handleDKey',
-      'm': '_handleMKey',
-      's': '_handleSKey',
-      'u': '_handleUKey',
-      'x': '_handleXKey',
-      'z': '_handleZKey',
-      ',': '_handleCommaKey',
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+        [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+        [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+        [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
+            '_handleOpenDownloadDialogShortcut',
+        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
+        [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+        [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+        [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      };
     },
 
     attached() {
@@ -336,7 +347,7 @@
       });
     },
 
-    _handleMKey(e) {
+    _handleToggleDiffMode(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -711,10 +722,17 @@
       this.viewState.numFilesShown = numFilesShown;
     },
 
+    _handleMessageAnchorTap(e) {
+      const hash = MSG_PREFIX + e.detail.id;
+      const url = Gerrit.Nav.getUrlForChange(this._change,
+          this._patchRange.patchNum, this._patchRange.basePatchNum,
+          this._editMode, hash);
+      history.replaceState(null, '', url);
+    },
+
     _maybeScrollToMessage(hash) {
-      const msgPrefix = '#message-';
-      if (hash.startsWith(msgPrefix)) {
-        this.messagesList.scrollToMessage(hash.substr(msgPrefix.length));
+      if (hash.startsWith(MSG_PREFIX)) {
+        this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
       }
     },
 
@@ -894,7 +912,7 @@
       return label;
     },
 
-    _handleAKey(e) {
+    _handleOpenReplyDialog(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) {
         return;
@@ -910,7 +928,7 @@
       });
     },
 
-    _handleDKey(e) {
+    _handleOpenDownloadDialogShortcut(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -918,13 +936,13 @@
       this.$.downloadOverlay.open();
     },
 
-    _handleCapitalRKey(e) {
+    _handleRefreshChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       e.preventDefault();
       Gerrit.Nav.navigateToChange(this._change);
     },
 
-    _handleSKey(e) {
+    _handleToggleChangeStar(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -932,7 +950,7 @@
       this.$.changeStar.toggleStar();
     },
 
-    _handleUKey(e) {
+    _handleUpToDashboard(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -940,7 +958,7 @@
       this._determinePageBack();
     },
 
-    _handleXKey(e) {
+    _handleExpandAllMessages(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -948,7 +966,7 @@
       this.messagesList.handleExpandCollapse(true);
     },
 
-    _handleZKey(e) {
+    _handleCollapseAllMessages(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -956,7 +974,7 @@
       this.messagesList.handleExpandCollapse(false);
     },
 
-    _handleCommaKey(e) {
+    _handleOpenDiffPrefsShortcut(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -1642,5 +1660,9 @@
       this.$.restAPI.saveChangeStarred(e.detail.change._number,
           e.detail.starred);
     },
+
+    _computeCurrentRevision(currentRevision, revisions) {
+      return revisions && revisions[currentRevision];
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 4e6cb6e..4b6cc6c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -43,6 +43,18 @@
 
 <script>
   suite('gr-change-view tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
+    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
+    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+
     let element;
     let sandbox;
     let navigateToChangeStub;
@@ -82,6 +94,20 @@
       return element.getComputedStyleValue(cssParam);
     };
 
+    test('_handleMessageAnchorTap', () => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChange');
+      const replaceStateStub = sandbox.stub(history, 'replaceState');
+      element._handleMessageAnchorTap({detail: {id: 'a12345'}});
+
+      assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+      assert.isTrue(replaceStateStub.called);
+    });
+
     suite('keyboard shortcuts', () => {
       test('S should toggle the CL star', () => {
         const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
@@ -277,11 +303,11 @@
         flushAsynchronousOperations();
 
         element.viewState.diffMode = 'SIDE_BY_SIDE';
-        element._handleMKey(e);
+        element._handleToggleDiffMode(e);
         assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
 
         element.viewState.diffMode = 'UNIFIED_DIFF';
-        element._handleMKey(e);
+        element._handleToggleDiffMode(e);
         assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
       });
     });
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 20449b0..5fc81e8 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -115,8 +115,8 @@
      * @return {string} Not sure why there was a mismatch
      */
     _computeDownloadLink(change, patchNum, opt_zip) {
-      return this.changeBaseURL(change._number, patchNum) + '/patch?' +
-          (opt_zip ? 'zip' : 'download');
+      return this.changeBaseURL(change.project, change._number, patchNum) +
+          '/patch?' + (opt_zip ? 'zip' : 'download');
     },
 
 
@@ -139,7 +139,7 @@
     },
 
     _computeArchiveDownloadLink(change, patchNum, format) {
-      return this.changeBaseURL(change._number, patchNum) +
+      return this.changeBaseURL(change.project, change._number, patchNum) +
           '/archive?format=' + format;
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 19932c5..2915e29 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -172,8 +172,8 @@
 
       test('computed fields', () => {
         assert.equal(element._computeArchiveDownloadLink(
-            {_number: 123}, 2, 'tgz'),
-            '/changes/123/revisions/2/archive?format=tgz');
+            {project: 'test/project', _number: 123}, 2, 'tgz'),
+            '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
       });
 
       test('close event', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index f54e058..42c9e88 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -197,23 +197,33 @@
     ],
 
     keyBindings: {
-      'shift+left': '_handleShiftLeftKey',
-      'shift+right': '_handleShiftRightKey',
-      'i:keyup': '_handleIKey',
-      'shift+i:keyup': '_handleCapitalIKey',
-      'down j': '_handleDownKey',
-      'up k': '_handleUpKey',
-      'c': '_handleCKey',
-      '[': '_handleLeftBracketKey',
-      ']': '_handleRightBracketKey',
-      'o': '_handleOKey',
-      'n': '_handleNKey',
-      'p': '_handlePKey',
-      'r': '_handleRKey',
-      'shift+a': '_handleCapitalAKey',
-      'esc': '_handleEscKey',
+      esc: '_handleEscKey',
     },
 
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+        [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+        [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+        [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+        [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+        [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
+        [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
+        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+        [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+        [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+        [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
+        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+        [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+        // Final two are actually handled by gr-diff-comment-thread.
+        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+      };
+    },
     listeners: {
       keydown: '_scopedKeydownHandler',
     },
@@ -232,7 +242,7 @@
     _scopedKeydownHandler(e) {
       if (e.keyCode === 13) {
         // Enter.
-        this._handleOKey(e);
+        this._handleOpenFile(e);
       }
     },
 
@@ -536,7 +546,7 @@
       this._togglePathExpanded(path);
     },
 
-    _handleShiftLeftKey(e) {
+    _handleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
         return;
       }
@@ -545,7 +555,7 @@
       this.$.diffCursor.moveLeft();
     },
 
-    _handleShiftRightKey(e) {
+    _handleRightPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
         return;
       }
@@ -554,7 +564,7 @@
       this.$.diffCursor.moveRight();
     },
 
-    _handleIKey(e) {
+    _handleToggleInlineDiff(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) ||
           this.$.fileCursor.index === -1) { return; }
@@ -563,14 +573,14 @@
       this._togglePathExpandedByIndex(this.$.fileCursor.index);
     },
 
-    _handleCapitalIKey(e) {
+    _handleToggleAllInlineDiffs(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._toggleInlineDiffs();
     },
 
-    _handleDownKey(e) {
+    _handleCursorNext(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
         return;
       }
@@ -588,7 +598,7 @@
       }
     },
 
-    _handleUpKey(e) {
+    _handleCursorPrev(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
         return;
       }
@@ -606,7 +616,7 @@
       }
     },
 
-    _handleCKey(e) {
+    _handleNewComment(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -619,7 +629,7 @@
       }
     },
 
-    _handleLeftBracketKey(e) {
+    _handleOpenLastFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -628,7 +638,7 @@
       this._openSelectedFile(this._files.length - 1);
     },
 
-    _handleRightBracketKey(e) {
+    _handleOpenFirstFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -637,7 +647,7 @@
       this._openSelectedFile(0);
     },
 
-    _handleOKey(e) {
+    _handleOpenFile(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
       e.preventDefault();
@@ -650,7 +660,7 @@
       this._openSelectedFile();
     },
 
-    _handleNKey(e) {
+    _handleNextChunk(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
           this._noDiffsExpanded()) {
@@ -665,7 +675,7 @@
       }
     },
 
-    _handlePKey(e) {
+    _handlePrevChunk(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
           this._noDiffsExpanded()) {
@@ -680,7 +690,7 @@
       }
     },
 
-    _handleRKey(e) {
+    _handleToggleFileReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
         return;
       }
@@ -690,7 +700,7 @@
       this._reviewFile(this._files[this.$.fileCursor.index].__path);
     },
 
-    _handleCapitalAKey(e) {
+    _handleToggleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 88b5f66..df92b1e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -49,6 +49,24 @@
 
 <script>
   suite('gr-file-list tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
+    kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
+    kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
+    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
     let element;
     let commentApiWrapper;
     let sandbox;
@@ -668,7 +686,7 @@
         assert.equal(getNumReviewed(), 0);
       });
 
-      suite('_handleOKey', () => {
+      suite('_handleOpenFile', () => {
         let interact;
 
         setup(() => {
@@ -686,7 +704,7 @@
 
             const e = new CustomEvent('fake-keyboard-event', opt_payload);
             sinon.stub(e, 'preventDefault');
-            element._handleOKey(e);
+            element._handleOpenFile(e);
             assert.isTrue(e.preventDefault.called);
             const result = {};
             if (openCursorStub.called) {
@@ -1545,7 +1563,7 @@
 
       setup(() => {
         sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nKeySpy = sandbox.spy(element, '_handleNKey');
+        nKeySpy = sandbox.spy(element, '_handleNextChunk');
         nextCommentStub = sandbox.stub(element.$.diffCursor,
             'moveToNextCommentThread');
         nextChunkStub = sandbox.stub(element.$.diffCursor,
@@ -1632,11 +1650,11 @@
       const mockEvent = {preventDefault() {}};
 
       element._displayLine = false;
-      element._handleDownKey(mockEvent);
+      element._handleCursorNext(mockEvent);
       assert.isTrue(element._displayLine);
 
       element._displayLine = false;
-      element._handleUpKey(mockEvent);
+      element._handleCursorPrev(mockEvent);
       assert.isTrue(element._displayLine);
 
       element._displayLine = true;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 0682ab2..f472331 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -69,7 +69,8 @@
     },
 
     _computeBlankItems(permittedLabels, label, side) {
-      if (!permittedLabels || !permittedLabels[label] || !this.labelValues ||
+      if (!permittedLabels || !permittedLabels[label] ||
+          !permittedLabels[label].length || !this.labelValues ||
           !Object.keys(this.labelValues).length) {
         return [];
       }
@@ -135,7 +136,8 @@
     },
 
     _computeAnyPermittedLabelValues(permittedLabels, label) {
-      return permittedLabels.hasOwnProperty(label);
+      return permittedLabels.hasOwnProperty(label) &&
+        permittedLabels[label].length;
     },
 
     _computeHiddenClass(permittedLabels, label) {
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index e5431f6..1e4d471 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -258,6 +258,11 @@
       flushAsynchronousOperations();
       assert.isOk(element.$$('iron-selector'));
       assert.isTrue(element.$$('iron-selector').hidden);
+
+      element.permittedLabels = {Verified: []};
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('iron-selector'));
+      assert.isTrue(element.$$('iron-selector').hidden);
     });
 
     test('asymetrical labels', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 19b0716b..32c4c1f 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -132,9 +132,12 @@
         right: var(--default-horizontal-margin);
         top: 10px;
       }
-      .date {
+      span.date {
         color: var(--deemphasized-text-color);
       }
+      span.date:hover {
+        text-decoration: underline;
+      }
       .dateContainer iron-icon {
         cursor: pointer;
       }
@@ -227,12 +230,12 @@
             </span>
           </template>
           <template is="dom-if" if="[[message.id]]">
-            <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
+            <span class="date" on-tap="_handleAnchorTap">
               <gr-date-formatter
                   has-tooltip
                   show-date-and-time
                   date-str="[[message.date]]"></gr-date-formatter>
-            </a>
+            </span>
           </template>
           <iron-icon
               id="expandToggle"
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 0590c73..8ecd6b0 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -24,17 +24,17 @@
     is: 'gr-message',
 
     /**
-     * Fired when this message's permalink is tapped.
-     *
-     * @event scroll-to
-     */
-
-    /**
      * Fired when this message's reply link is tapped.
      *
      * @event reply
      */
 
+    /**
+     * Fired when the message's timestamp is tapped.
+     *
+     * @event message-anchor-tap
+     */
+
     listeners: {
       tap: '_handleTap',
     },
@@ -223,22 +223,12 @@
       return classes.join(' ');
     },
 
-    _computeMessageHash(message) {
-      return '#message-' + message.id;
-    },
-
-    _handleLinkTap(e) {
+    _handleAnchorTap(e) {
       e.preventDefault();
-
-      this.fire('scroll-to', {message: this.message}, {bubbles: false});
-
-      const hash = this._computeMessageHash(this.message);
-      // Don't add the hash to the window history if it's already there.
-      // Otherwise you mess up expected back button behavior.
-      if (window.location.hash == hash) { return; }
-      // Change the URL but don’t trigger a nav event. Otherwise it will
-      // reload the page.
-      page.show(window.location.pathname + hash, null, false);
+      this.dispatchEvent(new CustomEvent('message-anchor-tap', {
+        bubbles: true,
+        detail: {id: this.message.id},
+      }));
     },
 
     _handleReplyTap(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 870f366..64a5b26 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -164,6 +164,24 @@
       });
     });
 
+    test('clicking on date link fires event', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        id: '47c43261_55aa2c41',
+      };
+      flushAsynchronousOperations();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
     test('votes', () => {
       element.message = {
         author: {},
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 0a7dacc..80708a1 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -109,7 +109,7 @@
           hide-automated="[[_hideAutomated]]"
           project-name="[[projectName]]"
           show-reply-button="[[showReplyButtons]]"
-          on-scroll-to="_handleScrollTo"
+          on-message-anchor-tap="_handleAnchorTap"
           label-extremes="[[_labelExtremes]]"
           data-message-id$="[[message.id]]"></gr-message>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 89a3523..023431c 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -184,8 +184,8 @@
       this.handleExpandCollapse(!this._expanded);
     },
 
-    _handleScrollTo(e) {
-      this.scrollToMessage(e.detail.message.id);
+    _handleAnchorTap(e) {
+      this.scrollToMessage(e.detail.id);
     },
 
     _hasAutomatedMessages(messages) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 8aa8173..30ebc08 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -61,6 +61,9 @@
         flex-shrink: 0;
         width: 1.2em;
       }
+      .note {
+        color: var(--error-text-color);
+      }
       .relatedChanges a {
         display: inline-block;
       }
@@ -118,9 +121,11 @@
           </div>
         </template>
       </section>
-      <section hidden$="[[!_submittedTogether.length]]" hidden>
+      <section
+          id="submittedTogether"
+          class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]">
         <h4>Submitted together</h4>
-        <template is="dom-repeat" items="[[_submittedTogether]]" as="related">
+        <template is="dom-repeat" items="[[_submittedTogether.changes]]" as="related">
           <div class$="[[_computeChangeContainerClass(change, related)]]">
             <a href$="[[_computeChangeURL(related._number, related.project)]]"
                 class$="[[_computeLinkClass(related)]]"
@@ -133,6 +138,11 @@
                 class$="submittableCheck [[_computeLinkClass(related)]]">✓</span>
           </div>
         </template>
+        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
+          <div class="note">
+            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
+          </div>
+        </template>
       </section>
       <section hidden$="[[!_sameTopic.length]]" hidden>
         <h4>Same topic</h4>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index a7abf85..ff5b290 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -57,9 +57,10 @@
         type: Object,
         value() { return {changes: []}; },
       },
+      /** @type {?} */
       _submittedTogether: {
-        type: Array,
-        value() { return []; },
+        type: Object,
+        value() { return {changes: []}; },
       },
       _conflicts: {
         type: Array,
@@ -90,7 +91,7 @@
       this.hidden = true;
 
       this._relatedResponse = {changes: []};
-      this._submittedTogether = [];
+      this._submittedTogether = {changes: []};
       this._conflicts = [];
       this._cherryPicks = [];
       this._sameTopic = [];
@@ -339,5 +340,19 @@
       }
       return connected;
     },
+
+    _computeSubmittedTogetherClass(submittedTogether) {
+      if (!submittedTogether || (
+          submittedTogether.changes.length === 0 &&
+          !submittedTogether.non_visible_changes)) {
+        return 'hidden';
+      }
+      return '';
+    },
+
+    _computeNonVisibleChangesNote(n) {
+      const noun = n === 1 ? 'change' : 'changes';
+      return `(+ ${n} non-visible ${noun})`;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index ef4af16..bfeb694 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -398,7 +398,7 @@
         status: 'NEW',
       }];
       element._relatedResponse = {changes};
-      element._submittedTogether = changes;
+      element._submittedTogether = {changes};
       element._conflicts = changes;
       element._cherryPicks = changes;
       element._sameTopic = changes;
@@ -407,7 +407,7 @@
       element.clear();
       assert.isTrue(element.hidden);
       assert.equal(element._relatedResponse.changes.length, 0);
-      assert.equal(element._submittedTogether.length, 0);
+      assert.equal(element._submittedTogether.changes.length, 0);
       assert.equal(element._conflicts.length, 0);
       assert.equal(element._cherryPicks.length, 0);
       assert.equal(element._sameTopic.length, 0);
@@ -431,5 +431,83 @@
       element._computeChangeURL(123, 'abc/def', 12);
       assert.isTrue(getUrlStub.called);
     });
+
+    suite('submitted together changes', () => {
+      const change = {
+        project: 'foo/bar',
+        change_id: 'Ideadbeef',
+        commit: {
+          commit: 'deadbeef',
+          parents: [{commit: 'abc123'}],
+          author: {},
+          subject: 'do that thing',
+        },
+        _change_number: 12345,
+        _revision_number: 1,
+        _current_revision_number: 1,
+        status: 'NEW',
+      };
+
+      test('_computeSubmittedTogetherClass', () => {
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass(undefined),
+            'hidden');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({changes: []}),
+            'hidden');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({changes: [{}]}),
+            '');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({
+              changes: [],
+              non_visible_changes: 0,
+            }),
+            'hidden');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({
+              changes: [],
+              non_visible_changes: 1,
+            }),
+            '');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({
+              changes: [{}],
+              non_visible_changes: 1,
+            }),
+            '');
+      });
+
+      test('no submitted together changes', () => {
+        flushAsynchronousOperations();
+        assert.include(element.$.submittedTogether.className, 'hidden');
+      });
+
+      test('no non-visible submitted together changes', () => {
+        element._submittedTogether = {changes: [change]};
+        flushAsynchronousOperations();
+        assert.notInclude(element.$.submittedTogether.className, 'hidden');
+        assert.isNull(element.$$('.note'));
+      });
+
+      test('no visible submitted together changes', () => {
+        // Technically this should never happen, but worth asserting the logic.
+        element._submittedTogether = {changes: [], non_visible_changes: 1};
+        flushAsynchronousOperations();
+        assert.notInclude(element.$.submittedTogether.className, 'hidden');
+        assert.isNotNull(element.$$('.note'));
+        assert.strictEqual(
+            element.$$('.note').innerText, '(+ 1 non-visible change)');
+      });
+
+      test('visible and non-visible submitted together changes', () => {
+        element._submittedTogether = {changes: [change], non_visible_changes: 2};
+        flushAsynchronousOperations();
+        assert.notInclude(element.$.submittedTogether.className, 'hidden');
+        assert.isNotNull(element.$$('.note'));
+        assert.strictEqual(
+            element.$$('.note').innerText, '(+ 2 non-visible changes)');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
index a9843a3..792c300 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
@@ -17,6 +17,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -47,9 +48,12 @@
         <ol>
           <li>
             <p>
-              Checkout this change locally and make your desired modifications to
-              the files.
+              Checkout this change locally and make your desired modifications
+              to the files.
             </p>
+            <template is="dom-if" if="[[_fetchCommand]]">
+              <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
+            </template>
           </li>
           <li>
             <p>
@@ -71,6 +75,7 @@
         </ol>
       </div>
     </gr-dialog>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-upload-help-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
index 548116c..02d00cf 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -20,6 +20,13 @@
   const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
   const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
 
+  // Command names correspond to download plugin definitions.
+  const PREFERRED_FETCH_COMMAND_ORDER = [
+    'checkout',
+    'cherry pick',
+    'pull',
+  ];
+
   Polymer({
     is: 'gr-upload-help-dialog',
 
@@ -30,23 +37,83 @@
      */
 
     properties: {
+      revision: Object,
       targetBranch: String,
       _commitCommand: {
         type: String,
         value: COMMIT_COMMAND,
         readOnly: true,
       },
+      _fetchCommand: {
+        type: String,
+        computed: '_computeFetchCommand(revision, ' +
+            '_preferredDownloadCommand, _preferredDownloadScheme)',
+      },
+      _preferredDownloadCommand: String,
+      _preferredDownloadScheme: String,
       _pushCommand: {
         type: String,
         computed: '_computePushCommand(targetBranch)',
       },
     },
 
+    attached() {
+      this.$.restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          return this.$.restAPI.getPreferences();
+        }
+      }).then(prefs => {
+        if (prefs) {
+          this._preferredDownloadCommand = prefs.download_command;
+          this._preferredDownloadScheme = prefs.download_scheme;
+        }
+      });
+    },
+
     _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
 
+    _computeFetchCommand(revision, preferredDownloadCommand,
+        preferredDownloadScheme) {
+      if (!revision) { return; }
+      if (!revision || !revision.fetch) { return; }
+
+      let scheme = preferredDownloadScheme;
+      if (!scheme) {
+        const keys = Object.keys(revision.fetch).sort();
+        if (keys.length === 0) {
+          return;
+        }
+        scheme = keys[0];
+      }
+
+      if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
+        return;
+      }
+
+      const cmds = {};
+      Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
+        cmds[key.toLowerCase()] = cmd;
+      });
+
+      if (preferredDownloadCommand &&
+          cmds[preferredDownloadCommand.toLowerCase()]) {
+        return cmds[preferredDownloadCommand.toLowerCase()];
+      }
+
+      // If no supported command preference is given, look for known commands
+      // from the downloads plugin in order of preference.
+      for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
+        if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
+          return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
+        }
+      }
+
+      return undefined;
+    },
+
     _computePushCommand(targetBranch) {
       return PUSH_COMMAND_PREFIX + targetBranch;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
index 60fe3e6..a5a6e76 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -48,5 +48,67 @@
       assert.equal(element._pushCommand,
           'git push origin HEAD:refs/for/master');
     });
+
+    suite('fetch command', () => {
+      const testRev = {
+        fetch: {
+          http: {
+            commands: {
+              Checkout: 'http checkout',
+              Pull: 'http pull',
+            },
+          },
+          ssh: {
+            commands: {
+              Pull: 'ssh pull',
+            },
+          },
+        },
+      };
+
+      test('null cases', () => {
+        assert.isUndefined(element._computeFetchCommand());
+        assert.isUndefined(element._computeFetchCommand({}));
+        assert.isUndefined(element._computeFetchCommand({fetch: null}));
+        assert.isUndefined(element._computeFetchCommand({fetch: {}}));
+      });
+
+      test('insufficiently defined scheme', () => {
+        assert.isUndefined(
+            element._computeFetchCommand(testRev, undefined, 'badscheme'));
+
+        const rev = Object.assign({}, testRev);
+        rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
+        assert.isUndefined(
+            element._computeFetchCommand(rev, undefined, 'nocmds'));
+
+        rev.fetch.nocmds.commands.unsupported = 'unsupported';
+        assert.isUndefined(
+            element._computeFetchCommand(rev, undefined, 'nocmds'));
+      });
+
+      test('default scheme and command', () => {
+        const cmd = element._computeFetchCommand(testRev);
+        assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
+      });
+
+      test('default command', () => {
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, undefined, 'http'),
+            'http checkout');
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, undefined, 'ssh'),
+            'ssh pull');
+      });
+
+      test('user preferred scheme and command', () => {
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, 'PULL', 'http'),
+            'http pull');
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, 'badcmd', 'http'),
+            'http checkout');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
new file mode 100644
index 0000000..2ff7953
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
@@ -0,0 +1,48 @@
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-key-binding-display">
+  <template>
+    <style include="shared-styles">
+      .key {
+        background-color: var(--chip-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: 3px;
+        display: inline-block;
+        font-weight: var(--font-weight-bold);
+        padding: .1em .5em;
+        text-align: center;
+      }
+    </style>
+    <template is="dom-repeat" items="[[binding]]">
+      <template is="dom-if" if="[[index]]">
+        or
+      </template>
+      <template
+          is="dom-repeat"
+          items="[[_computeModifiers(item)]]"
+          as="modifier">
+        <span class="key modifier">[[modifier]]</span>
+      </template>
+      <span class="key">[[_computeKey(item)]]</span>
+    </template>
+  </template>
+  <script src="gr-key-binding-display.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
new file mode 100644
index 0000000..89d1091
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-key-binding-display',
+
+    properties: {
+      /** @type {Array<string>} */
+      binding: Array,
+    },
+
+    _computeModifiers(binding) {
+      return binding.slice(0, binding.length - 1);
+    },
+
+    _computeKey(binding) {
+      return binding[binding.length - 1];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
new file mode 100644
index 0000000..0361d76
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-key-binding-display</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-key-binding-display.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-key-binding-display></gr-key-binding-display>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-key-binding-display tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    suite('_computeKey', () => {
+      test('unmodified key', () => {
+        assert.strictEqual(element._computeKey(['x']), 'x');
+      });
+
+      test('key with modifiers', () => {
+        assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
+        assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
+      });
+    });
+
+    suite('_computeModifiers', () => {
+      test('single unmodified key', () => {
+        assert.deepEqual(element._computeModifiers(['x']), []);
+      });
+
+      test('key with modifiers', () => {
+        assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
+        assert.deepEqual(
+            element._computeModifiers(['Shift', 'Meta', 'x']),
+            ['Shift', 'Meta']);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 9fda898..e3552cc 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -16,7 +16,9 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../gr-key-binding-display/gr-key-binding-display.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-keyboard-shortcuts-dialog">
@@ -54,15 +56,6 @@
         font-weight: var(--font-weight-bold);
         padding-top: 1em;
       }
-      .key {
-        background-color: var(--chip-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: 3px;
-        display: inline-block;
-        font-weight: var(--font-weight-bold);
-        padding: .1em .5em;
-        text-align: center;
-      }
       .modifier {
         font-weight: normal;
       }
@@ -74,449 +67,42 @@
     <main>
       <table>
         <tbody>
-          <tr>
-            <td></td><td class="header">Everywhere</td>
-          </tr>
-          <tr>
-            <td><span class="key">/</span></td>
-            <td>Search</td>
-          </tr>
-          <tr>
-            <td><span class="key">?</span></td>
-            <td>Show this dialog</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">g</span>
-              <span class="key">o</span>
-            </td>
-            <td>Go to Opened Changes</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">g</span>
-              <span class="key">m</span>
-            </td>
-            <td>Go to Merged Changes</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">g</span>
-              <span class="key">a</span>
-            </td>
-            <td>Go to Abandoned Changes</td>
-          </tr>
-        </tbody>
-        <!-- Change View -->
-        <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
-          <tr>
-            <td></td><td class="header">Navigation</td>
-          </tr>
-          <tr>
-            <td><span class="key">]</span></td>
-            <td>Show first file</td>
-          </tr>
-          <tr>
-            <td><span class="key">[</span></td>
-            <td>Show last file</td>
-          </tr>
-          <tr>
-            <td><span class="key">u</span></td>
-            <td>Up to dashboard</td>
-          </tr>
-        </tbody>
-        <!-- Diff View -->
-        <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden>
-          <tr>
-            <td></td><td class="header">Navigation</td>
-          </tr>
-          <tr>
-            <td><span class="key">]</span></td>
-            <td>Show next file</td>
-          </tr>
-          <tr>
-            <td><span class="key">[</span></td>
-            <td>Show previous file</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">j</span>
-            </td>
-            <td>Show next file that has comments</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">k</span>
-            </td>
-            <td>Show previous file that has comments</td>
-          </tr>
-          <tr>
-            <td><span class="key">u</span></td>
-            <td>Up to change</td>
-          </tr>
+          <template is="dom-repeat" items="[[_left]]">
+            <tr>
+              <td></td><td class="header">[[item.section]]</td>
+            </tr>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </template>
         </tbody>
       </table>
-
-      <table>
-        <!-- Change List -->
-        <tbody hidden$="[[!_computeInView(view, 'search')]]" hidden>
-          <tr>
-            <td></td><td class="header">Change list</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span></td>
-            <td>Select next change</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span></td>
-            <td>Show previous change</td>
-          </tr>
-          <tr>
-            <td><span class="key">n</span> or <span class="key">]</span></td>
-            <td>Go to next page</td>
-          </tr>
-          <tr>
-            <td><span class="key">p</span> or <span class="key">[</span></td>
-            <td>Go to previous page</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">Enter</span> or
-              <span class="key">o</span>
-            </td>
-            <td>Show selected change</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">r</span>
-            </td>
-            <td>Refresh list of changes</td>
-          </tr>
-          <tr>
-            <td><span class="key">s</span></td>
-            <td>Star (or unstar) change</td>
-          </tr>
-        </tbody>
-        <!-- Dashboard -->
-        <tbody hidden$="[[!_computeInView(view, 'dashboard')]]" hidden>
-          <tr>
-            <td></td><td class="header">Dashboard</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span></td>
-            <td>Select next change</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span></td>
-            <td>Show previous change</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">Enter</span> or
-              <span class="key">o</span>
-            </td>
-            <td>Show selected change</td>
-          </tr>
-          <tr>
-            <td><span class="key">r</span></td>
-            <td>Mark (or unmark) change as reviewed</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">r</span>
-            </td>
-            <td>Refresh list of changes</td>
-          </tr>
-          <tr>
-            <td><span class="key">s</span></td>
-            <td>Star (or unstar) change</td>
-          </tr>
-        </tbody>
-        <!-- Change View -->
-        <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
-          <tr>
-            <td></td><td class="header">Actions</td>
-          </tr>
-          <tr>
-            <td><span class="key">a</span></td>
-            <td>Open reply dialog to publish comments and add reviewers</td>
-          </tr>
-          <tr>
-            <td><span class="key">d</span></td>
-            <td>Open download overlay</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">r</span>
-            </td>
-            <td>Reload the change at the latest patch</td>
-          </tr>
-          <tr>
-            <td><span class="key">s</span></td>
-            <td>Star (or unstar) change</td>
-          </tr>
-          <tr>
-            <td><span class="key">x</span></td>
-            <td>Expand all messages</td>
-          </tr>
-          <tr>
-            <td><span class="key">z</span></td>
-            <td>Collapse all messages</td>
-          </tr>
-          <tr>
-            <td></td><td class="header">Reply dialog</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Ctrl</span>
-              <span class="key">Enter</span><br/>
-            </td>
-            <td>Send reply</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Meta</span>
-              <span class="key">Enter</span>
-            </td>
-            <td>Send reply</td>
-          </tr>
-          <tr>
-            <td></td><td class="header">File list</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span> or <span class="key">↓</span></td>
-            <td>Select next file</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span> or <span class="key">↑</span></td>
-            <td>Select previous file</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">Enter</span> or
-              <span class="key">o</span>
-            </td>
-            <td>Go to selected file</td>
-          </tr>
-          <tr>
-            <td><span class="key">r</span></td>
-            <td>Toggle review flag on selected file</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">i</span>
-            </td>
-            <td>Show/hide all inline diffs</td>
-          </tr>
-          <tr>
-            <td><span class="key">i</span></td>
-            <td>Show/hide selected inline diff</td>
-          </tr>
-          <tr>
-            <td></td><td class="header">Diffs</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span> or <span class="key">↓</span></td>
-            <td>Go to next line</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span> or <span class="key">↑</span></td>
-            <td>Go to previous line</td>
-          </tr>
-          <tr>
-            <td><span class="key">n</span></td>
-            <td>Go to next diff chunk</td>
-          </tr>
-          <tr>
-            <td><span class="key">p</span></td>
-            <td>Go to previous diff chunk</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">n</span>
-            </td>
-            <td>Go to next comment thread</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">p</span>
-            </td>
-            <td>Go to previous comment thread</td>
-          </tr>
-          <tr>
-            <td><span class="key">e</span></td>
-            <td>Expand all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">e</span>
-            </td>
-            <td>Collapse all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">←</span>
-            </td>
-            <td>Select left pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">→</span>
-            </td>
-            <td>Select right pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">a</span>
-            </td>
-            <td>Hide/show left diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">m</span>
-            </td>
-            <td>Toggle unified/side-by-side diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">c</span>
-            </td>
-            <td>Draft new comment</td>
-          </tr>
-        </tbody>
-        <!-- Diff View -->
-        <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden>
-          <tr>
-            <td></td><td class="header">Actions</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span> or <span class="key">↓</span></td>
-            <td>Show next line</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span> or <span class="key">↑</span></td>
-            <td>Show previous line</td>
-          </tr>
-          <tr>
-            <td><span class="key">n</span></td>
-            <td>Show next diff chunk</td>
-          </tr>
-          <tr>
-            <td><span class="key">p</span></td>
-            <td>Show previous diff chunk</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">x</span>
-            </td>
-            <td>Expand all diff context</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">n</span>
-            </td>
-            <td>Show next comment thread</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">p</span>
-            </td>
-            <td>Show previous comment thread</td>
-          </tr>
-          <tr>
-            <td><span class="key">e</span></td>
-            <td>Expand all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">e</span>
-            </td>
-            <td>Collapse all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">←</span>
-            </td>
-            <td>Select left pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">→</span>
-            </td>
-            <td>Select right pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">a</span>
-            </td>
-            <td>Hide/show left diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">m</span>
-            </td>
-            <td>Toggle unified/side-by-side diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">c</span>
-            </td>
-            <td>Draft new comment</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Ctrl</span>
-              <span class="key">s</span><br/>
-            </td>
-            <td>Save comment</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Ctrl</span>
-              <span class="key">Enter</span><br/>
-            </td>
-            <td>Save comment</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Meta</span>
-              <span class="key">Enter</span>
-            </td>
-            <td>Save comment</td>
-          </tr>
-          <tr>
-            <td><span class="key">a</span></td>
-            <td>Open reply dialog to publish comments and add reviewers</td>
-          </tr>
-          <tr>
-            <td><span class="key">,</span></td>
-            <td>Show diff preferences</td>
-          </tr>
-          <tr>
-            <td><span class="key">r</span></td>
-            <td>Mark/unmark file as reviewed</td>
-          </tr>
-        </tbody>
-      </table>
+      <template is="dom-if" if="[[_right]]">
+        <table>
+          <tbody>
+            <template is="dom-repeat" items="[[_right]]">
+              <tr>
+                <td></td><td class="header">[[item.section]]</td>
+              </tr>
+              <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+                <tr>
+                  <td>
+                    <gr-key-binding-display binding="[[shortcut.binding]]">
+                    </gr-key-binding-display>
+                  </td>
+                  <td>[[shortcut.text]]</td>
+                </tr>
+              </template>
+            </template>
+          </tbody>
+        </table>
+      </template>
     </main>
     <footer></footer>
   </template>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index e5dd019..5b29972 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -17,6 +17,8 @@
 (function() {
   'use strict';
 
+  const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder;
+
   Polymer({
     is: 'gr-keyboard-shortcuts-dialog',
 
@@ -27,20 +29,97 @@
      */
 
     properties: {
-      view: String,
+      _left: Array,
+      _right: Array,
+
+      _propertyBySection: {
+        type: Object,
+        value() {
+          return {
+            [ShortcutSection.EVERYWHERE]: '_everywhere',
+            [ShortcutSection.NAVIGATION]: '_navigation',
+            [ShortcutSection.DASHBOARD]: '_dashboard',
+            [ShortcutSection.CHANGE_LIST]: '_changeList',
+            [ShortcutSection.ACTIONS]: '_actions',
+            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
+            [ShortcutSection.FILE_LIST]: '_fileList',
+            [ShortcutSection.DIFFS]: '_diffs',
+          };
+        },
+      },
     },
 
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
     hostAttributes: {
       role: 'dialog',
     },
 
-    _computeInView(currentView, view) {
-      return view === currentView;
+    attached() {
+      this.addKeyboardShortcutDirectoryListener(
+          this._onDirectoryUpdated.bind(this));
+    },
+
+    detached() {
+      this.removeKeyboardShortcutDirectoryListener(
+          this._onDirectoryUpdated.bind(this));
     },
 
     _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
+
+    _onDirectoryUpdated(directory) {
+      const left = [];
+      const right = [];
+
+      if (directory.has(ShortcutSection.EVERYWHERE)) {
+        left.push({
+          section: ShortcutSection.EVERYWHERE,
+          shortcuts: directory.get(ShortcutSection.EVERYWHERE),
+        });
+      }
+
+      if (directory.has(ShortcutSection.NAVIGATION)) {
+        left.push({
+          section: ShortcutSection.NAVIGATION,
+          shortcuts: directory.get(ShortcutSection.NAVIGATION),
+        });
+      }
+
+      if (directory.has(ShortcutSection.ACTIONS)) {
+        right.push({
+          section: ShortcutSection.ACTIONS,
+          shortcuts: directory.get(ShortcutSection.ACTIONS),
+        });
+      }
+
+      if (directory.has(ShortcutSection.REPLY_DIALOG)) {
+        right.push({
+          section: ShortcutSection.REPLY_DIALOG,
+          shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
+        });
+      }
+
+      if (directory.has(ShortcutSection.FILE_LIST)) {
+        right.push({
+          section: ShortcutSection.FILE_LIST,
+          shortcuts: directory.get(ShortcutSection.FILE_LIST),
+        });
+      }
+
+      if (directory.has(ShortcutSection.DIFFS)) {
+        right.push({
+          section: ShortcutSection.DIFFS,
+          shortcuts: directory.get(ShortcutSection.DIFFS),
+        });
+      }
+
+      this.set('_left', left);
+      this.set('_right', right);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
new file mode 100644
index 0000000..50579dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
@@ -0,0 +1,179 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-key-binding-display</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-keyboard-shortcuts-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-keyboard-shortcuts-dialog tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    function update(directory) {
+      element._onDirectoryUpdated(directory);
+      flushAsynchronousOperations();
+    }
+
+    suite('_left and _right contents', () => {
+      test('empty dialog', () => {
+        assert.strictEqual(element._left.length, 0);
+        assert.strictEqual(element._right.length, 0);
+      });
+
+      test('everywhere goes on left', () => {
+        update(new Map([
+          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._left,
+            [
+              {
+                section: kb.ShortcutSection.EVERYWHERE,
+                shortcuts: ['everywhere shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._right.length, 0);
+      });
+
+      test('navigation goes on left', () => {
+        update(new Map([
+          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._left,
+            [
+              {
+                section: kb.ShortcutSection.NAVIGATION,
+                shortcuts: ['navigation shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._right.length, 0);
+      });
+
+      test('actions go on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.ACTIONS,
+                shortcuts: ['actions shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('reply dialog goes on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.REPLY_DIALOG,
+                shortcuts: ['reply dialog shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('file list goes on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.FILE_LIST,
+                shortcuts: ['file list shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('diffs go on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.DIFFS,
+                shortcuts: ['diffs shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('multiple sections on each side', () => {
+        update(new Map([
+          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._left,
+            [
+              {
+                section: kb.ShortcutSection.EVERYWHERE,
+                shortcuts: ['everywhere shortcuts'],
+              },
+              {
+                section: kb.ShortcutSection.NAVIGATION,
+                shortcuts: ['navigation shortcuts'],
+              },
+            ]);
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.ACTIONS,
+                shortcuts: ['actions shortcuts'],
+              },
+              {
+                section: kb.ShortcutSection.DIFFS,
+                shortcuts: ['diffs shortcuts'],
+              },
+            ]);
+      });
+    });
+  });
+</script>
+
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index bc31b60..5be259c 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -33,6 +33,8 @@
     //        also be provided.
     //    - `edit`, optional, Boolean: whether or not to load the file list with
     //        edit controls.
+    //    - `messageHash`, optional, String: the hash of the change message to
+    //        scroll to.
     //
     // - Gerrit.Nav.View.SEARCH:
     //    - `query`, optional, String: the literal search query. If provided,
@@ -352,9 +354,11 @@
        * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
        *     used for none.
        * @param {boolean=} opt_isEdit
+       * @param {string=} opt_messageHash
        * @return {string}
        */
-      getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
+      getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
+          opt_messageHash) {
         if (opt_basePatchNum === PARENT_PATCHNUM) {
           opt_basePatchNum = undefined;
         }
@@ -368,6 +372,7 @@
           basePatchNum: opt_basePatchNum,
           edit: opt_isEdit,
           host: change.internalHost || undefined,
+          messageHash: opt_messageHash,
         });
       },
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index bdd0942..2ea7e37 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -395,6 +395,9 @@
       } else if (params.edit) {
         suffix += ',edit';
       }
+      if (params.messageHash) {
+        suffix += params.messageHash;
+      }
       if (params.project) {
         const encodedProject = this.encodeURL(params.project, true);
         return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index 53a7c07..270faf0 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -281,6 +281,9 @@
         paramsWithQuery.basePatchNum = 5;
         assert.equal(element._generateUrl(paramsWithQuery),
             '/c/test/+/1234/5..10?revert&foo=bar');
+
+        params.messageHash = '#123';
+        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
       });
 
       test('change with repo name encoding', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 1513a7f..a81526c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -110,10 +110,6 @@
       Gerrit.URLEncodingBehavior,
     ],
 
-    keyBindings: {
-      '/': '_handleForwardSlashKey',
-    },
-
     properties: {
       value: {
         type: String,
@@ -156,6 +152,12 @@
       },
     },
 
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.SEARCH]: '_handleSearch',
+      };
+    },
+
     _valueChanged(value) {
       this._inputVal = value;
     },
@@ -274,7 +276,7 @@
           });
     },
 
-    _handleForwardSlashKey(e) {
+    _handleSearch(e) {
       const keyboardEvent = this.getKeyboardEvent(e);
       if (this.shouldSuppressKeyboardShortcut(e) ||
           (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 9a7023e..93e0e307 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -37,6 +37,9 @@
 
 <script>
   suite('gr-search-bar tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.SEARCH, '/');
+
     let element;
     let sandbox;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 4b85c22..be53fda 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -67,6 +67,16 @@
         layer.addListener(this._handleLayerUpdate.bind(this));
       }
     }
+
+    const allComments = [];
+    for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
+      // This is needed by the threading.
+      for (const comment of this._comments[side]) {
+        comment.__commentSide = side;
+      }
+      allComments.push(...this._comments[side]);
+    }
+    this._threads = this._createThreads(allComments);
   }
 
   GrDiffBuilder.GroupType = {
@@ -319,44 +329,51 @@
     return button;
   };
 
-  GrDiffBuilder.prototype._getCommentsForLine = function(comments, line,
-      opt_side) {
-    function byLineNum(lineNum) {
-      return function(c) {
-        return (c.line === lineNum) ||
-               (c.line === undefined && lineNum === GrDiffLine.FILE);
-      };
+  /**
+   * @param {!Array<Object>} threads
+   * @param {!GrDiffLine} line
+   * @param {!GrDiffBuilder.Side=} side The side (LEFT, RIGHT, BOTH) for which
+   *     to return the threads (default: BOTH).
+   */
+  GrDiffBuilder.prototype._filterThreadsForLine = function(
+      threads, line, side = GrDiffBuilder.Side.BOTH) {
+    function matchesLeftLine(thread) {
+      return thread.commentSide == GrDiffBuilder.Side.LEFT &&
+          thread.comments[0].line == line.beforeNumber;
     }
-    const leftComments =
-        comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
-    const rightComments =
-        comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
-
-    leftComments.forEach(c => { c.__commentSide = 'left'; });
-    rightComments.forEach(c => { c.__commentSide = 'right'; });
-
-    let result;
-
-    switch (opt_side) {
-      case GrDiffBuilder.Side.LEFT:
-        result = leftComments;
-        break;
-      case GrDiffBuilder.Side.RIGHT:
-        result = rightComments;
-        break;
-      default:
-        result = leftComments.concat(rightComments);
-        break;
+    function matchesRightLine(thread) {
+      return thread.commentSide == GrDiffBuilder.Side.RIGHT &&
+          thread.comments[0].line == line.afterNumber;
+    }
+    function matchesFileComment(thread) {
+      return (side === GrDiffBuilder.Side.BOTH ||
+              thread.commentSide == side) &&
+             // line/range comments have 1-based line set, if line is falsy it's
+             // a file comment
+             !thread.comments[0].line;
     }
 
-    return result;
+    // Select the appropriate matchers for the desired side and line
+    // If side is BOTH, we want both the left and right matcher.
+    const matchers = [];
+    if (side !== GrDiffBuilder.Side.RIGHT) {
+      matchers.push(matchesLeftLine);
+    }
+    if (side !== GrDiffBuilder.Side.LEFT) {
+      matchers.push(matchesRightLine);
+    }
+    if (line.afterNumber === GrDiffLine.FILE ||
+        line.beforeNumber === GrDiffLine.FILE) {
+      matchers.push(matchesFileComment);
+    }
+
+    return threads.filter(thread => matchers.find(matcher => matcher(thread)));
   };
 
   /**
    * @param {Array<Object>} comments
-   * @param {string} patchForNewThreads
    */
-  GrDiffBuilder.prototype._getThreads = function(comments, patchForNewThreads) {
+  GrDiffBuilder.prototype._createThreads = function(comments) {
     const sortedComments = comments.slice(0).sort((a, b) => {
       if (b.__draft && !a.__draft ) { return 0; }
       if (a.__draft && !b.__draft ) { return 1; }
@@ -381,13 +398,7 @@
         start_datetime: comment.updated,
         comments: [comment],
         commentSide: comment.__commentSide,
-        /**
-         * Determines what the patchNum of a thread should be. Use patchNum from
-         * comment if it exists, otherwise the property of the thread group.
-         * This is needed for switching between side-by-side and unified views
-         * when there are unsaved drafts.
-         */
-        patchNum: comment.patch_set || patchForNewThreads,
+        patchNum: comment.patch_set,
         rootId: comment.id || comment.__draftID,
       };
       if (comment.range) {
@@ -439,28 +450,30 @@
   };
 
   /**
-   * @param {GrDiffLine} line
-   * @param {string=} opt_side
+   * @param {!GrDiffLine} line
+   * @param {!GrDiffBuilder.Side=} side The side (LEFT, RIGHT, BOTH) for which to return
+   *     the thread group (default: BOTH).
    * @return {!Object}
    */
   GrDiffBuilder.prototype._commentThreadGroupForLine = function(
-      line, opt_side) {
-    const comments =
-    this._getCommentsForLine(this._comments, line, opt_side);
-    if (!comments || comments.length === 0) {
+      line, side = GrDiffBuilder.Side.BOTH) {
+    const threads =
+        this._filterThreadsForLine(this._threads, line, side);
+    if (!threads || threads.length === 0) {
       return null;
     }
 
-    const patchNum = this._determinePatchNumForNewThreads(
-        this._comments.meta.patchRange, line, opt_side);
+    const patchRange = this._comments.meta.patchRange;
+    const patchNumForNewThread = this._determinePatchNumForNewThreads(
+        patchRange, line, side);
     const isOnParent = this._determineIsOnParent(
-        comments[0].side, this._comments.meta.patchRange, line, opt_side);
+        threads[0].side, patchRange, line, side);
 
-    const threadGroupEl = this._createThreadGroupFn(patchNum, isOnParent,
-        opt_side);
-    threadGroupEl.threads = this._getThreads(comments, patchNum);
-    if (opt_side) {
-      threadGroupEl.setAttribute('data-side', opt_side);
+    const threadGroupEl = this._createThreadGroupFn(
+        patchNumForNewThread, isOnParent, side);
+    threadGroupEl.threads = threads;
+    if (side) {
+      threadGroupEl.setAttribute('data-side', side);
     }
     return threadGroupEl;
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 3238cbc..80b45a4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -57,6 +57,7 @@
 
 <script>
   suite('gr-diff-builder tests', () => {
+    let prefs;
     let element;
     let builder;
     let createThreadGroupFn;
@@ -70,7 +71,7 @@
         getLoggedIn() { return Promise.resolve(false); },
         getProjectConfig() { return Promise.resolve({}); },
       });
-      const prefs = {
+      prefs = {
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
@@ -84,8 +85,7 @@
 
     teardown(() => { sandbox.restore(); });
 
-    test('_getThreads', () => {
-      const patchForNewThreads = 3;
+    test('_createThreads', () => {
       const comments = [
         {
           id: 'sallys_confession',
@@ -108,7 +108,7 @@
         },
       ];
 
-      let expectedThreadGroups = [
+      const expectedThreadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           commentSide: 'left',
@@ -124,8 +124,8 @@
             __commentSide: 'left',
             in_reply_to: 'sallys_confession',
           }],
+          patchNum: undefined,
           rootId: 'sallys_confession',
-          patchNum: 3,
         },
         {
           start_datetime: '2015-12-20 15:01:20.396000000',
@@ -139,17 +139,18 @@
               updated: '2015-12-20 15:01:20.396000000',
             },
           ],
+          patchNum: undefined,
           rootId: 'new_draft',
-          patchNum: 3,
         },
       ];
 
       assert.deepEqual(
-          builder._getThreads(comments, patchForNewThreads),
+          builder._createThreads(comments),
           expectedThreadGroups);
+    });
 
-      // Patch num should get inherited from comment rather
-      comments.push({
+    test('_createThreads inherits patchNum amd range', () => {
+      const comments = [{
         id: 'betsys_confession',
         message: 'i like you, jack',
         updated: '2015-12-24 15:00:10.396000000',
@@ -159,29 +160,12 @@
           end_line: 1,
           end_character: 2,
         },
+        patch_set: 5,
         __commentSide: 'left',
-      });
+      }];
 
       expectedThreadGroups = [
         {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            in_reply_to: 'sallys_confession',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-          }],
-          patchNum: 3,
-          rootId: 'sallys_confession',
-        },
-        {
           start_datetime: '2015-12-24 15:00:10.396000000',
           commentSide: 'left',
           comments: [{
@@ -194,9 +178,10 @@
               end_line: 1,
               end_character: 2,
             },
+            patch_set: 5,
             __commentSide: 'left',
           }],
-          patchNum: 3,
+          patchNum: 5,
           rootId: 'betsys_confession',
           range: {
             start_line: 1,
@@ -205,25 +190,10 @@
             end_character: 2,
           },
         },
-        {
-          start_datetime: '2015-12-20 15:01:20.396000000',
-          commentSide: 'left',
-          comments: [
-            {
-              id: 'new_draft',
-              message: 'i do not like either of you',
-              __commentSide: 'left',
-              __draft: true,
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-          rootId: 'new_draft',
-          patchNum: 3,
-        },
       ];
 
       assert.deepEqual(
-          builder._getThreads(comments, patchForNewThreads),
+          builder._createThreads(comments),
           expectedThreadGroups);
     });
 
@@ -241,7 +211,7 @@
           __commentSide: 'left',
         },
       ];
-      assert.equal(builder._getThreads(comments, '3').length, 2);
+      assert.equal(builder._createThreads(comments).length, 2);
     });
 
     test('_createElement classStr applies all classes', () => {
@@ -417,37 +387,78 @@
       }
     });
 
-    test('comments', () => {
+    test('_filterThreadsForLine with no threads', () => {
       const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 3;
       line.afterNumber = 5;
 
-      let comments = {left: [], right: []};
-      assert.deepEqual(builder._getCommentsForLine(comments, line), []);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
+      const threads = [];
+      assert.deepEqual(
+          builder._filterThreadsForLine(threads, line), []);
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
           GrDiffBuilder.Side.LEFT), []);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
           GrDiffBuilder.Side.RIGHT), []);
+    });
 
-      comments = {
-        left: [
-          {id: 'l3', line: 3},
-          {id: 'l5', line: 5},
-        ],
-        right: [
-          {id: 'r3', line: 3},
-          {id: 'r5', line: 5},
-        ],
+    test('_filterThreadsForLine for line comments', () => {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const l3 = {
+        comments: [{id: 'l3', line: 3}],
+        range: {end_line: 3},
+        commentSide: 'left',
       };
-      assert.deepEqual(builder._getCommentsForLine(comments, line),
-          [{id: 'l3', line: 3, __commentSide: 'left'},
-          {id: 'r5', line: 5, __commentSide: 'right'}]);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3,
-            __commentSide: 'left'}]);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5,
-            __commentSide: 'right'}]);
+      const l5 = {
+        comments: [{id: 'l5', line: 5}],
+        range: {end_line: 5},
+        commentSide: 'left',
+      };
+      const r3 = {
+        comments: [{id: 'r3', line: 3}],
+        range: {end_line: 3},
+        commentSide: 'right',
+      };
+      const r5 = {
+        comments: [{id: 'r5', line: 5}],
+        range: {end_line: 5},
+        commentSide: 'right',
+      };
+
+      const threads = [l3, l5, r3, r5];
+      assert.deepEqual(builder._filterThreadsForLine(threads, line),
+          [l3, r5]);
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
+          GrDiffBuilder.Side.LEFT), [l3]);
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
+          GrDiffBuilder.Side.RIGHT), [r5]);
+    });
+
+    test('_filterThreadsForLine for file comments', () => {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = GrDiffLine.FILE;
+      line.afterNumber = GrDiffLine.FILE;
+
+      const l = {
+        comments: [{id: 'l', line: undefined}],
+        commentSide: 'left',
+      };
+      const r = {
+        comments: [{id: 'r', line: undefined}],
+        commentSide: 'right',
+      };
+
+      const threads = [l, r];
+      assert.deepEqual(builder._filterThreadsForLine(threads, line),
+          [l, r]);
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
+          GrDiffBuilder.Side.BOTH), [l, r]);
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
+          GrDiffBuilder.Side.LEFT), [l]);
+      assert.deepEqual(builder._filterThreadsForLine(threads, line,
+          GrDiffBuilder.Side.RIGHT), [r]);
     });
 
     test('comment thread group creation', () => {
@@ -458,19 +469,20 @@
       const r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
         __commentSide: 'right'};
 
-      builder._comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: '3',
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [l3, l5],
-        right: [r5],
-      };
+      builder = new GrDiffBuilder(
+          {content: []}, {
+            meta: {
+              changeNum: '42',
+              patchRange: {
+                basePatchNum: 'PARENT',
+                patchNum: '3',
+              },
+              path: '/path/to/foo',
+              projectConfig: {foo: 'bar'},
+            },
+            left: [l3, l5],
+            right: [r5],
+          }, createThreadGroupFn, prefs);
 
       function threadForComment(c, patchNum) {
         return {
@@ -488,7 +500,7 @@
         assert.equal(createThreadGroupFn.lastCall.args[1], isOnParent);
         assert.deepEqual(
             threadGroupEl.threads,
-            comments.map(c => threadForComment(c, patchNum)));
+            comments.map(c => threadForComment(c, undefined)));
       }
 
       let line = new GrDiffLine(GrDiffLine.Type.BOTH);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
index ae45c93..23d0a58 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
@@ -24,7 +24,6 @@
       changeNum: String,
       projectName: String,
       patchForNewThreads: String,
-      range: Object,
       isOnParent: {
         type: Boolean,
         value: false,
@@ -105,29 +104,5 @@
           a.endLine === b.endLine &&
           a.endChar === b.endChar;
     },
-
-    _sortByDate(threadGroups) {
-      if (!threadGroups.length) { return; }
-      return threadGroups.sort((a, b) => {
-        // If a comment is a draft, it doesn't have a start_datetime yet.
-        // Assume it is newer than the comment it is being compared to.
-        if (!a.start_datetime) {
-          return 1;
-        }
-        if (!b.start_datetime) {
-          return -1;
-        }
-        return util.parseDate(a.start_datetime) -
-            util.parseDate(b.start_datetime);
-      });
-    },
-
-    _calculateLocationRange(range, comment) {
-      return 'range-' + range.start_line + '-' +
-          range.start_character + '-' +
-          range.end_line + '-' +
-          range.end_character + '-' +
-          comment.__commentSide;
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
index 1fb8136..8897ea9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
@@ -122,76 +122,6 @@
       assert.deepEqual(element.getThread('left', range).comments.length, 1);
     });
 
-    test('_sortByDate', () => {
-      let threadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-        {
-          start_datetime: '2015-12-22 15:00:10.396000000',
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        },
-      ];
-
-      let expectedResult = [
-        {
-          start_datetime: '2015-12-22 15:00:10.396000000',
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        }, {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-      ];
-
-      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
-
-      // When a comment doesn't have a date, the one without the date should be
-      // last.
-      threadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-        {
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        },
-      ];
-
-      expectedResult = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-        {
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        },
-      ];
-
-      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
-    });
-
-    test('_calculateLocationRange', () => {
-      const comment = {__commentSide: 'left'};
-      const range = {
-        start_line: 1,
-        start_character: 2,
-        end_line: 3,
-        end_character: 4,
-      };
-      assert.equal(
-          element._calculateLocationRange(range, comment),
-          'range-1-2-3-4-left');
-    });
-
     test('addNewThread', () => {
       const locationRange = 'range-1-2-3-4';
       element._threads = [{locationRange: 'line'}];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index cee3cad..577eec6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -35,7 +35,7 @@
     listeners: {
       'comment-mouse-out': '_handleCommentMouseOut',
       'comment-mouse-over': '_handleCommentMouseOver',
-      'create-comment': '_createComment',
+      'create-range-comment': '_createRangeComment',
     },
 
     observers: [
@@ -171,13 +171,19 @@
       }
       const start = range.start;
       const end = range.end;
+      // Happens when triple click in side-by-side mode with other side empty.
+      const endsAtOtherEmptySide = !end &&
+          domRange.endOffset === 0 &&
+          domRange.endContainer.nodeName === 'TD' &&
+          (domRange.endContainer.classList.contains('left') ||
+           domRange.endContainer.classList.contains('right'));
       const endsAtBeginningOfNextLine = end &&
           start.column === 0 &&
           end.column === 0 &&
           end.line === start.line + 1;
       const content = domRange.cloneContents().querySelector('.contentText');
       const lineLength = content && this._getLength(content) || 0;
-      if (lineLength && endsAtBeginningOfNextLine) {
+      if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
         // Move the selection to the end of the previous line.
         range.end = {
           node: start.node,
@@ -311,7 +317,7 @@
       }
     },
 
-    _createComment(e) {
+    _createRangeComment(e) {
       this._removeActionBox();
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 7b19338..98d55c0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -123,7 +123,7 @@
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText">in physicis, quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+            <td class="content both"><div class="contentText"></div></td>
             <td class="right lineNum" data-value="147"></td>
             <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
           </tr>
@@ -215,9 +215,9 @@
         assert.isFalse(builder.getContentsByLineRange.called);
       });
 
-      test('on create-comment action box is removed', () => {
+      test('on create-range-comment action box is removed', () => {
         sandbox.stub(element, '_removeActionBox');
-        element.fire('create-comment', {
+        element.fire('create-range-comment', {
           comment: {
             range: {},
           },
@@ -589,6 +589,21 @@
         });
         assert.equal(getActionSide(), 'right');
       });
+
+      test('_fixTripleClickSelection empty line', () => {
+        const startContent = stubContent(146, 'right');
+        const endContent = stubContent(165, 'left');
+        emulateSelection(startContent.firstChild, 0,
+            endContent.parentElement.previousElementSibling, 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 146,
+          startChar: 0,
+          endLine: 146,
+          endChar: 84,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 6f61fb9..19fb308 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -177,7 +177,7 @@
     },
 
     listeners: {
-      'draft-interaction': '_handleDraftInteraction',
+      'create-comment': '_handleCreateComment',
     },
 
     observers: [
@@ -445,11 +445,35 @@
           this.patchRange);
     },
 
-    _handleDraftInteraction() {
+    /** @param {CustomEvent} e */
+    _handleCreateComment(e) {
+      const {threadGroupEl, lineNum, side, range} = e.detail;
+      const threadEl = this._getOrCreateThread(threadGroupEl, side, range);
+      threadEl.addOrEditDraft(lineNum, range);
       this.$.reporting.recordDraftInteraction();
     },
 
     /**
+     * Gets or creates a comment thread from a specific thread group.
+     * May include a range, if the comment is a range comment.
+     *
+     * @param {!Object} threadGroupEl
+     * @param {string} commentSide
+     * @param {!Object=} range
+     * @return {!Object}
+     */
+    _getOrCreateThread(threadGroupEl, commentSide, range=undefined) {
+      let threadEl = threadGroupEl.getThread(commentSide, range);
+
+      if (!threadEl) {
+        threadGroupEl.addNewThread(commentSide, range);
+        Polymer.dom.flush();
+        threadEl = threadGroupEl.getThread(commentSide, range);
+      }
+      return threadEl;
+    },
+
+    /**
      * Take a diff that was loaded with a ignore-whitespace other than
      * IGNORE_NONE, and convert delta chunks labeled as common into shared
      * chunks.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index f83253e..3374686 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -776,6 +776,29 @@
       });
     });
 
+    test('_getOrCreateThread', () => {
+      const threadGroupEl =
+          document.createElement('gr-diff-comment-thread-group');
+      const commentSide = 'left';
+
+      assert.isOk(element._getOrCreateThread(threadGroupEl,
+          commentSide));
+
+      // Try to fetch a thread with a different range.
+      range = {
+        startLine: 1,
+        startChar: 1,
+        endLine: 1,
+        endChar: 3,
+      };
+
+      assert.isOk(element._getOrCreateThread(
+          threadGroupEl, commentSide, range));
+      const threadCount = Polymer.dom(threadGroupEl.root).
+            querySelectorAll('gr-diff-comment-thread').length;
+      assert.equal(threadCount, 2);
+    });
+
     suite('_translateChunksToIgnore', () => {
       let content;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 35e2fe1..27e467d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -179,10 +179,19 @@
       const startLineEl =
           this.diffBuilder.getLineElByChild(range.startContainer);
       const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+      // Happens when triple click in side-by-side mode with other side empty.
+      const endsAtOtherEmptySide = !endLineEl &&
+          range.endOffset === 0 &&
+          range.endContainer.nodeName === 'TD' &&
+          (range.endContainer.classList.contains('left') ||
+           range.endContainer.classList.contains('right'));
       const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      const endLineNum = endLineEl === null ?
-          undefined :
-          parseInt(endLineEl.getAttribute('data-value'), 10);
+      let endLineNum;
+      if (endsAtOtherEmptySide) {
+        endLineNum = startLineNum + 1;
+      } else if (endLineEl) {
+        endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+      }
 
       return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
           range.endOffset, side);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 04d53a4..0866849 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -269,7 +269,7 @@
             <a
               class="downloadLink"
               download
-              href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
+              href$="[[_computeDownloadLink(_change.project, _changeNum, _patchRange, _path)]]">
               Download
             </a>
           </span>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index b0eb423..6f361d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -177,22 +177,41 @@
     ],
 
     keyBindings: {
-      'esc': '_handleEscKey',
-      'shift+left': '_handleShiftLeftKey',
-      'shift+right': '_handleShiftRightKey',
-      'up k': '_handleUpKey',
-      'down j': '_handleDownKey',
-      'c': '_handleCKey',
-      '[': '_handleLeftBracketKey',
-      ']': '_handleRightBracketKey',
-      'n shift+n': '_handleNKey',
-      'p shift+p': '_handlePKey',
-      'a shift+a': '_handleAKey',
-      'u': '_handleUKey',
-      ',': '_handleCommaKey',
-      'm': '_handleMKey',
-      'r': '_handleRKey',
-      'shift+x': '_handleShiftXKey',
+      esc: '_handleEscKey',
+    },
+
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+        [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+        [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+        [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
+            '_handleNextLineOrFileWithComments',
+        [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
+            '_handlePrevLineOrFileWithComments',
+        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+        [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+        [this.Shortcut.NEXT_FILE]: '_handleNextFile',
+        [this.Shortcut.PREV_FILE]: '_handlePrevFile',
+        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+        [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+        [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+        [this.Shortcut.OPEN_REPLY_DIALOG]:
+            '_handleOpenReplyDialogOrToggleLeftPane',
+        [this.Shortcut.TOGGLE_LEFT_PANE]:
+            '_handleOpenReplyDialogOrToggleLeftPane',
+        [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+        [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+
+        // Final two are actually handled by gr-diff-comment-thread.
+        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+      };
     },
 
     attached() {
@@ -263,7 +282,7 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
-    _handleRKey(e) {
+    _handleToggleFileReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -279,21 +298,21 @@
       this.$.diffHost.displayLine = false;
     },
 
-    _handleShiftLeftKey(e) {
+    _handleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveLeft();
     },
 
-    _handleShiftRightKey(e) {
+    _handleRightPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveRight();
     },
 
-    _handleUpKey(e) {
+    _handlePrevLineOrFileWithComments(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 75) { // 'K'
@@ -307,7 +326,7 @@
       this.$.cursor.moveUp();
     },
 
-    _handleDownKey(e) {
+    _handleNextLineOrFileWithComments(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 74) { // 'J'
@@ -348,7 +367,7 @@
           this._patchRange.patchNum, this._patchRange.basePatchNum);
     },
 
-    _handleCKey(e) {
+    _handleNewComment(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (this.$.diffHost.isRangeSelected()) { return; }
       if (this.modifierPressed(e)) { return; }
@@ -360,7 +379,7 @@
       }
     },
 
-    _handleLeftBracketKey(e) {
+    _handlePrevFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -369,7 +388,7 @@
       this._navToFile(this._path, this._fileList, -1);
     },
 
-    _handleRightBracketKey(e) {
+    _handleNextFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -378,7 +397,7 @@
       this._navToFile(this._path, this._fileList, 1);
     },
 
-    _handleNKey(e) {
+    _handleNextChunkOrCommentThread(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -390,7 +409,7 @@
       }
     },
 
-    _handlePKey(e) {
+    _handlePrevChunkOrCommentThread(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -402,7 +421,7 @@
       }
     },
 
-    _handleAKey(e) {
+    _handleOpenReplyDialogOrToggleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
@@ -420,7 +439,7 @@
       this._navToChangeView();
     },
 
-    _handleUKey(e) {
+    _handleUpToChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -436,7 +455,7 @@
       this.$.diffPreferences.open();
     },
 
-    _handleMKey(e) {
+    _handleToggleDiffMode(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -864,8 +883,8 @@
       history.replaceState(null, '', url);
     },
 
-    _computeDownloadLink(changeNum, patchRange, path) {
-      let url = this.changeBaseURL(changeNum, patchRange.patchNum);
+    _computeDownloadLink(project, changeNum, patchRange, path) {
+      let url = this.changeBaseURL(project, changeNum, patchRange.patchNum);
       url += '/patch?zip&path=' + encodeURIComponent(path);
       return url;
     },
@@ -989,7 +1008,7 @@
       return '';
     },
 
-    _handleShiftXKey(e) {
+    _handleExpandAllDiffContext(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       this.$.diffHost.expandAllContext();
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 00527e4..431578b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -43,6 +43,31 @@
 
 <script>
   suite('gr-diff-view tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s');
+    kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']');
+    kb.bindShortcut(kb.Shortcut.PREV_FILE, '[');
+    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u');
+    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
+    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+
     let element;
     let sandbox;
 
@@ -531,6 +556,7 @@
     });
 
     test('download link', () => {
+      element._change = {project: 'test'},
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: PARENT,
@@ -541,7 +567,7 @@
       flushAsynchronousOperations();
       const link = element.$$('.downloadLink');
       assert.equal(link.getAttribute('href'),
-          '/changes/42/revisions/10/patch?zip&path=glados.txt');
+          '/changes/test~42/revisions/10/patch?zip&path=glados.txt');
       assert.isTrue(link.hasAttribute('download'));
     });
 
@@ -838,16 +864,16 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
-    test('_handleMKey', () => {
+    test('_handleToggleDiffMode', () => {
       sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const e = {preventDefault: () => {}};
       // Initial state.
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
 
-      element._handleMKey(e);
+      element._handleToggleDiffMode(e);
       assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
 
-      element._handleMKey(e);
+      element._handleToggleDiffMode(e);
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 3ae8806..69ac283 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -60,9 +60,9 @@
      */
 
      /**
-      * Fired when a draft is added or edited.
+      * Fired when a comment is created
       *
-      * @event draft-interaction
+      * @event create-comment
       */
 
     properties: {
@@ -207,7 +207,7 @@
       'comment-discard': '_handleCommentDiscard',
       'comment-update': '_handleCommentUpdate',
       'comment-save': '_handleCommentSave',
-      'create-comment': '_handleCreateComment',
+      'create-range-comment': '_handleCreateRangeComment',
     },
 
     /** Cancel any remaining diff builder rendering work. */
@@ -322,7 +322,7 @@
       this._createComment(el, lineNum);
     },
 
-    _handleCreateComment(e) {
+    _handleCreateRangeComment(e) {
       const range = e.detail.range;
       const side = e.detail.side;
       const lineNum = range.endLine;
@@ -359,35 +359,29 @@
 
     /**
      * @param {!Object} lineEl
-     * @param {number=} opt_lineNum
-     * @param {string=} opt_side
-     * @param {!Object=} opt_range
+     * @param {number=} lineNum
+     * @param {string=} side
+     * @param {!Object=} range
      */
-    _createComment(lineEl, opt_lineNum, opt_side, opt_range) {
-      this.dispatchEvent(new CustomEvent('draft-interaction', {bubbles: true}));
+    _createComment(lineEl, lineNum=undefined, side=undefined, range=undefined) {
       const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       const contentEl = contentText.parentElement;
-      const side = opt_side ||
+      side = side ||
           this._getCommentSideByLineAndContent(lineEl, contentEl);
       const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
       const isOnParent =
         this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      const threadEl = this._getOrCreateThread(contentEl, patchNum,
-          side, isOnParent, opt_range);
-      threadEl.addOrEditDraft(opt_lineNum, opt_range);
-    },
-
-    /**
-     * Fetch the thread group at the given range, or the range-less thread
-     * on the line if no range is provided.
-     *
-     * @param {!Object} threadGroupEl
-     * @param {string} commentSide
-     * @param {!Object=} opt_range
-     * @return {!Object}
-     */
-    _getThread(threadGroupEl, commentSide, opt_range) {
-      return threadGroupEl.getThread(commentSide, opt_range);
+      const threadGroupEl = this._getOrCreateThreadGroup(contentEl, patchNum,
+          side, isOnParent);
+      this.dispatchEvent(new CustomEvent('create-comment', {
+        bubbles: true,
+        detail: {
+          threadGroupEl,
+          lineNum,
+          side,
+          range,
+        },
+      }));
     },
 
     _getThreadGroupForLine(contentEl) {
@@ -395,18 +389,15 @@
     },
 
     /**
-     * Gets or creates a comment thread for a specific spot on a diff.
-     * May include a range, if the comment is a range comment.
-     *
+     * Gets or creates a comment thread group for a specific line and side on a
+     * diff.
      * @param {!Object} contentEl
      * @param {number} patchNum
      * @param {string} commentSide
      * @param {boolean} isOnParent
-     * @param {!Object=} opt_range
      * @return {!Object}
      */
-    _getOrCreateThread(contentEl, patchNum, commentSide,
-        isOnParent, opt_range) {
+    _getOrCreateThreadGroup(contentEl, patchNum, commentSide, isOnParent) {
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
@@ -414,15 +405,7 @@
             commentSide);
         contentEl.appendChild(threadGroupEl);
       }
-
-      let threadEl = this._getThread(threadGroupEl, commentSide, opt_range);
-
-      if (!threadEl) {
-        threadGroupEl.addNewThread(commentSide, opt_range);
-        Polymer.dom.flush();
-        threadEl = this._getThread(threadGroupEl, commentSide, opt_range);
-      }
-      return threadEl;
+      return threadGroupEl;
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index faf529b..07584c7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -298,12 +298,6 @@
         const commentSide = 'left';
         const patchNum = 1;
         const side = 'PARENT';
-        let range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 2,
-        };
 
         element.changeNum = 123;
         element.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -311,36 +305,22 @@
 
         const mock = document.createElement('mock-diff-response');
         element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-            mock.diffResponse, {}, {tab_size: 2, line_length: 80});
+            mock.diffResponse, {left: [], right: []},
+            {tab_size: 2, line_length: 80});
 
         // No thread groups.
         assert.isNotOk(element._getThreadGroupForLine(contentEl));
 
         // A thread group gets created.
-        assert.isOk(element._getOrCreateThread(contentEl,
-            patchNum, commentSide, side));
+        const threadGroupEl = element._getOrCreateThreadGroup(contentEl,
+            patchNum, commentSide, side);
+        assert.isOk(threadGroupEl);
 
-        // Try to fetch a thread with a different range.
-        range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 3,
-        };
-
-        assert.isOk(element._getOrCreateThread(
-            contentEl, patchNum, commentSide, side, range));
         // The new thread group can be fetched.
         assert.isOk(element._getThreadGroupForLine(contentEl));
 
         assert.equal(contentEl.querySelectorAll(
             'gr-diff-comment-thread-group').length, 1);
-
-        const threadGroup = contentEl.querySelector(
-            'gr-diff-comment-thread-group');
-        const threadLength = Polymer.dom(threadGroup.root).
-              querySelectorAll('gr-diff-comment-thread').length;
-        assert.equal(threadLength, 2);
       });
 
       suite('image diffs', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 6349ab6..0f84877 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -23,7 +23,7 @@
     /**
      * Fired when the comment creation action was taken (hotkey, click).
      *
-     * @event create-comment
+     * @event create-range-comment
      */
 
     properties: {
@@ -110,7 +110,7 @@
     },
 
     _fireCreateComment() {
-      this.fire('create-comment', {side: this.side, range: this.range});
+      this.fire('create-range-comment', {side: this.side, range: this.range});
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 19155e4..4f1065a 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -98,7 +98,7 @@
       element.range = range;
       MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert(element.fire.calledWithExactly(
-          'create-comment',
+          'create-range-comment',
           {
             side,
             range,
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 0508608..b0cc514 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -17,10 +17,6 @@
 (function() {
   'use strict';
 
-  // The maximum age of a keydown event to be used in a jump navigation. This is
-  // only for cases when the keyup event is lost.
-  const G_KEY_TIMEOUT_MS = 1000;
-
   // Eagerly render Polymer components when backgrounded. (Skips
   // requestAnimationFrame.)
   // @see https://github.com/Polymer/polymer/issues/3851
@@ -112,11 +108,17 @@
       Gerrit.KeyboardShortcutBehavior,
     ],
 
-    keyBindings: {
-      '?': '_showKeyboardShortcuts',
-      'g:keydown': '_gKeyDown',
-      'g:keyup': '_gKeyUp',
-      'a m o': '_jumpKeyPressed',
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+        [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+        [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+        [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      };
+    },
+
+    created() {
+      this._bindKeyboardShortcuts();
     },
 
     ready() {
@@ -171,6 +173,118 @@
       };
     },
 
+    _bindKeyboardShortcuts() {
+      this.bindShortcut(this.Shortcut.SEND_REPLY,
+          this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
+
+      this.bindShortcut(
+          this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_PREV_CHANGE, 'k');
+      this.bindShortcut(
+          this.Shortcut.OPEN_CHANGE, 'o');
+      this.bindShortcut(
+          this.Shortcut.NEXT_PAGE, 'n', ']');
+      this.bindShortcut(
+          this.Shortcut.PREV_PAGE, 'p', '[');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_CHANGE_STAR, 's');
+      this.bindShortcut(
+          this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_REPLY_DIALOG, 'a');
+      this.bindShortcut(
+          this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+      this.bindShortcut(
+          this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+      this.bindShortcut(
+          this.Shortcut.REFRESH_CHANGE, 'shift+r');
+      this.bindShortcut(
+          this.Shortcut.UP_TO_DASHBOARD, 'u');
+      this.bindShortcut(
+          this.Shortcut.UP_TO_CHANGE, 'u');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_DIFF_MODE, 'm');
+
+      this.bindShortcut(
+          this.Shortcut.NEXT_LINE, 'j', 'down');
+      this.bindShortcut(
+          this.Shortcut.PREV_LINE, 'k', 'up');
+      this.bindShortcut(
+          this.Shortcut.NEXT_CHUNK, 'n');
+      this.bindShortcut(
+          this.Shortcut.PREV_CHUNK, 'p');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      this.bindShortcut(
+          this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+      this.bindShortcut(
+          this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+      this.bindShortcut(
+          this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+          this.DOC_ONLY, 'shift+e');
+      this.bindShortcut(
+          this.Shortcut.LEFT_PANE, 'shift+left');
+      this.bindShortcut(
+          this.Shortcut.RIGHT_PANE, 'shift+right');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+      this.bindShortcut(
+          this.Shortcut.NEW_COMMENT, 'c');
+      this.bindShortcut(
+          this.Shortcut.SAVE_COMMENT,
+          'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+      this.bindShortcut(
+          this.Shortcut.OPEN_DIFF_PREFS, ',');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r');
+
+      this.bindShortcut(
+          this.Shortcut.NEXT_FILE, ']');
+      this.bindShortcut(
+          this.Shortcut.PREV_FILE, '[');
+      this.bindShortcut(
+          this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+      this.bindShortcut(
+          this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+      this.bindShortcut(
+          this.Shortcut.OPEN_FILE, 'o', 'enter');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_FIRST_FILE, ']');
+      this.bindShortcut(
+          this.Shortcut.OPEN_LAST_FILE, '[');
+
+      this.bindShortcut(
+          this.Shortcut.SEARCH, '/');
+    },
+
     _accountChanged(account) {
       if (!account) { return; }
 
@@ -293,32 +407,16 @@
       return isShadowDom ? 'shadow' : '';
     },
 
-    _gKeyDown(e) {
-      if (this.modifierPressed(e)) { return; }
-      this._lastGKeyPressTimestamp = Date.now();
+    _goToOpenedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('open');
     },
 
-    _gKeyUp() {
-      this._lastGKeyPressTimestamp = null;
+    _goToMergedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('merged');
     },
 
-    _jumpKeyPressed(e) {
-      if (!this._lastGKeyPressTimestamp ||
-          (Date.now() - this._lastGKeyPressTimestamp > G_KEY_TIMEOUT_MS) ||
-          this.shouldSuppressKeyboardShortcut(e)) { return; }
-      e.preventDefault();
-
-      let status = null;
-      if (e.detail.key === 'a') {
-        status = 'abandoned';
-      } else if (e.detail.key === 'm') {
-        status = 'merged';
-      } else if (e.detail.key === 'o') {
-        status = 'open';
-      }
-      if (status !== null) {
-        Gerrit.Nav.navigateToStatusSearch(status);
-      }
+    _goToAbandonedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('abandoned');
     },
 
     _computePluginScreenName({plugin, screen}) {
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index fb1b241..734d2fe 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -114,55 +114,5 @@
       element._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
       assert.ok(element._lastSearchPage);
     });
-
-    suite('_jumpKeyPressed', () => {
-      let navStub;
-
-      setup(() => {
-        navStub = sandbox.stub(Gerrit.Nav, 'navigateToStatusSearch');
-        sandbox.stub(Date, 'now').returns(10000);
-      });
-
-      test('success', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = 9000;
-        element._jumpKeyPressed(e);
-        assert.isTrue(navStub.calledOnce);
-        assert.equal(navStub.lastCall.args[0], 'abandoned');
-      });
-
-      test('no g key', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = null;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-
-      test('g key too long ago', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = 3000;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-
-      test('should suppress', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-        element._lastGKeyPressTimestamp = 9000;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-
-      test('unrecognized key', () => {
-        const e = {detail: {key: 'f'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = 9000;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index b4a36b0..c0078e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -135,6 +135,42 @@
   const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
       '/revisions/*';
 
+  /**
+   * Wrapper around Map for caching server responses. Site-based so that
+   * changes to CANONICAL_PATH will result in a different cache going into
+   * effect.
+   */
+  class SiteBasedCache {
+    constructor() {
+      // Container of per-canonical-path caches.
+      this._data = new Map();
+    }
+
+    // Returns the cache for the current canonical path.
+    _cache() {
+      if (!this._data.has(window.CANONICAL_PATH)) {
+        this._data.set(window.CANONICAL_PATH, new Map());
+      }
+      return this._data.get(window.CANONICAL_PATH);
+    }
+
+    has(key) {
+      return this._cache().has(key);
+    }
+
+    get(key) {
+      return this._cache().get(key);
+    }
+
+    set(key, value) {
+      this._cache().set(key, value);
+    }
+
+    delete(key) {
+      this._cache().delete(key);
+    }
+  }
+
   Polymer({
     is: 'gr-rest-api-interface',
 
@@ -171,7 +207,7 @@
     properties: {
       _cache: {
         type: Object,
-        value: {}, // Intentional to share the object across instances.
+        value: new SiteBasedCache(), // Shared across instances.
       },
       _credentialCheck: {
         type: Object,
@@ -268,7 +304,7 @@
         }
         return res;
       }).catch(err => {
-        const isLoggedIn = !!this._cache['/accounts/self/detail'];
+        const isLoggedIn = !!this._cache.get('/accounts/self/detail');
         if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
           this.checkCredentials();
           return;
@@ -784,7 +820,7 @@
      */
     saveDiffPreferences(prefs, opt_errFn) {
       // Invalidate the cache.
-      this._cache['/accounts/self/preferences.diff'] = undefined;
+      this._cache.delete('/accounts/self/preferences.diff');
       return this._send({
         method: 'PUT',
         url: '/accounts/self/preferences.diff',
@@ -800,7 +836,7 @@
      */
     saveEditPreferences(prefs, opt_errFn) {
       // Invalidate the cache.
-      this._cache['/accounts/self/preferences.edit'] = undefined;
+      this._cache.delete('/accounts/self/preferences.edit');
       return this._send({
         method: 'PUT',
         url: '/accounts/self/preferences.edit',
@@ -816,7 +852,7 @@
         reportUrlAsIs: true,
         errFn: resp => {
           if (!resp || resp.status === 403) {
-            this._cache['/accounts/self/detail'] = null;
+            this._cache.delete('/accounts/self/detail');
           }
         },
       });
@@ -828,7 +864,7 @@
         reportUrlAsIs: true,
         errFn: resp => {
           if (!resp || resp.status === 403) {
-            this._cache['/accounts/self/avatar.change.url'] = null;
+            this._cache.delete('/accounts/self/avatar.change.url');
           }
         },
       });
@@ -910,7 +946,7 @@
       return this._send(req).then(() => {
         // If result of getAccountEmails is in cache, update it in the cache
         // so we don't have to invalidate it.
-        const cachedEmails = this._cache['/accounts/self/emails'];
+        const cachedEmails = this._cache.get('/accounts/self/emails');
         if (cachedEmails) {
           const emails = cachedEmails.map(entry => {
             if (entry.email === email) {
@@ -919,7 +955,7 @@
               return {email};
             }
           });
-          this._cache['/accounts/self/emails'] = emails;
+          this._cache.set('/accounts/self/emails', emails);
         }
       });
     },
@@ -930,11 +966,11 @@
     _updateCachedAccount(obj) {
       // If result of getAccount is in cache, update it in the cache
       // so we don't have to invalidate it.
-      const cachedAccount = this._cache['/accounts/self/detail'];
+      const cachedAccount = this._cache.get('/accounts/self/detail');
       if (cachedAccount) {
         // Replace object in cache with new object to force UI updates.
-        this._cache['/accounts/self/detail'] =
-            Object.assign({}, cachedAccount, obj);
+        this._cache.set('/accounts/self/detail',
+            Object.assign({}, cachedAccount, obj));
       }
     },
 
@@ -1064,14 +1100,14 @@
         if (!res) { return; }
         if (res.status === 403) {
           this.fire('auth-error');
-          this._cache['/accounts/self/detail'] = null;
+          this._cache.delete('/accounts/self/detail');
         } else if (res.ok) {
           return this.getResponseObject(res);
         }
       }).then(res => {
         this._credentialCheck.checking = false;
         if (res) {
-          this._cache['/accounts/self/detail'] = res;
+          this._cache.delete('/accounts/self/detail');
         }
         return res;
       }).catch(err => {
@@ -1154,13 +1190,13 @@
         return this._sharedFetchPromises[req.url];
       }
       // TODO(andybons): Periodic cache invalidation.
-      if (this._cache[req.url] !== undefined) {
-        return Promise.resolve(this._cache[req.url]);
+      if (this._cache.has(req.url)) {
+        return Promise.resolve(this._cache.get(req.url));
       }
       this._sharedFetchPromises[req.url] = this._fetchJSON(req)
           .then(response => {
             if (response !== undefined) {
-              this._cache[req.url] = response;
+              this._cache.set(req.url, response);
             }
             this._sharedFetchPromises[req.url] = undefined;
             return response;
@@ -1747,7 +1783,7 @@
     getChangesSubmittedTogether(changeNum) {
       return this._getChangeURLAndFetch({
         changeNum,
-        endpoint: '/submitted_together',
+        endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
         reportEndpointAsIs: true,
       });
     },
@@ -2050,10 +2086,17 @@
     },
 
     saveChangeStarred(changeNum, starred) {
-      return this._send({
-        method: starred ? 'PUT' : 'DELETE',
-        url: '/accounts/self/starred.changes/' + changeNum,
-        anonymizedUrl: '/accounts/self/starred.changes/*',
+      // Some servers may require the project name to be provided
+      // alongside the change number, so resolve the project name
+      // first.
+      return this.getFromProjectLookup(changeNum).then(project => {
+        const url = '/accounts/self/starred.changes/' +
+            (project ? encodeURIComponent(project) + '~' : '') + changeNum;
+        return this._send({
+          method: starred ? 'PUT' : 'DELETE',
+          url,
+          anonymizedUrl: '/accounts/self/starred.changes/*',
+        });
       });
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index d9656e4..eaac5ef 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -38,11 +38,15 @@
   suite('gr-rest-api-interface tests', () => {
     let element;
     let sandbox;
+    let ctr = 0;
 
     setup(() => {
+      // Modify CANONICAL_PATH to effectively reset cache.
+      ctr += 1;
+      window.CANONICAL_PATH = `test${ctr}`;
+
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element._cache = {};
       element._projectLookup = {};
       const testJSON = ')]}\'\n{"hello": "bonjour"}';
       sandbox.stub(window, 'fetch').returns(Promise.resolve({
@@ -85,7 +89,7 @@
 
     test('cached promise', done => {
       const promise = Promise.reject('foo');
-      element._cache['/foo'] = promise;
+      element._cache.set('/foo', promise);
       element._fetchSharedCacheURL({url: '/foo'}).catch(p => {
         assert.equal(p, 'foo');
         done();
@@ -98,19 +102,20 @@
         gr: 'guten tag',
         noval: null,
       });
-      assert.equal(url, '/path/?sp=hola&gr=guten%20tag&noval');
+      assert.equal(url,
+          window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
 
       url = element._urlWithParams('/path/', {
         sp: 'hola',
         en: ['hey', 'hi'],
       });
-      assert.equal(url, '/path/?sp=hola&en=hey&en=hi');
+      assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
 
       // Order must be maintained with array params.
       url = element._urlWithParams('/path/', {
         l: ['c', 'b', 'a'],
       });
-      assert.equal(url, '/path/?l=c&l=b&l=a');
+      assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
     });
 
     test('request callbacks can be canceled', done => {
@@ -441,7 +446,7 @@
           Promise.reject({message: 'Failed to fetch'}));
       window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
       // Emulate logged in.
-      element._cache['/accounts/self/detail'] = {};
+      element._cache.set('/accounts/self/detail', {});
       const serverErrorStub = sandbox.stub();
       element.addEventListener('server-error', serverErrorStub);
       const authErrorStub = sandbox.stub();
@@ -450,7 +455,7 @@
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
-          assert.isNull(element._cache['/accounts/self/detail']);
+          assert.isFalse(element._cache.has('/accounts/self/detail'));
           done();
         });
       });
@@ -471,7 +476,7 @@
       ];
       window.fetch.restore();
       sandbox.stub(window, 'fetch', url => {
-        if (url === '/accounts/self/detail') {
+        if (url === window.CANONICAL_PATH + '/accounts/self/detail') {
           return Promise.resolve(responses.shift());
         }
       });
@@ -487,7 +492,7 @@
 
     test('checkCredentials promise rejection', () => {
       window.fetch.restore();
-      element._cache['/accounts/self/detail'] = true;
+      element._cache.set('/accounts/self/detail', true);
       sandbox.spy(element, 'checkCredentials');
       sandbox.stub(window, 'fetch', url => {
         return Promise.reject({message: 'Failed to fetch'});
@@ -515,10 +520,10 @@
     test('saveDiffPreferences invalidates cache line', () => {
       const cacheKey = '/accounts/self/preferences.diff';
       sandbox.stub(element, '_send');
-      element._cache[cacheKey] = {tab_size: 4};
+      element._cache.set(cacheKey, {tab_size: 4});
       element.saveDiffPreferences({tab_size: 8});
       assert.isTrue(element._send.called);
-      assert.notOk(element._cache[cacheKey]);
+      assert.isFalse(element._cache.has(cacheKey));
     });
 
     test('getAccount when resp is null does not add anything to the cache',
@@ -529,11 +534,11 @@
 
           element.getAccount().then(() => {
             assert.isTrue(element._fetchSharedCacheURL.called);
-            assert.isNull(element._cache[cacheKey]);
+            assert.isFalse(element._cache.has(cacheKey));
             done();
           });
 
-          element._cache[cacheKey] = 'fake cache';
+          element._cache.set(cacheKey, 'fake cache');
           stub.lastCall.args[0].errFn();
         });
 
@@ -545,10 +550,10 @@
 
           element.getAccount().then(() => {
             assert.isTrue(element._fetchSharedCacheURL.called);
-            assert.isNull(element._cache[cacheKey]);
+            assert.isFalse(element._cache.has(cacheKey));
             done();
           });
-          element._cache[cacheKey] = 'fake cache';
+          element._cache.set(cacheKey, 'fake cache');
           stub.lastCall.args[0].errFn({status: 403});
         });
 
@@ -559,10 +564,10 @@
 
       element.getAccount().then(response => {
         assert.isTrue(element._fetchSharedCacheURL.called);
-        assert.equal(element._cache[cacheKey], 'fake cache');
+        assert.equal(element._cache.get(cacheKey), 'fake cache');
         done();
       });
-      element._cache[cacheKey] = 'fake cache';
+      element._cache.set(cacheKey, 'fake cache');
 
       stub.lastCall.args[0].errFn({});
     });
@@ -728,7 +733,7 @@
 
     test('setAccountStatus', () => {
       sandbox.stub(element, '_send').returns(Promise.resolve('OOO'));
-      element._cache['/accounts/self/detail'] = {};
+      element._cache.set('/accounts/self/detail', {});
       return element.setAccountStatus('OOO').then(() => {
         assert.isTrue(element._send.calledOnce);
         assert.equal(element._send.lastCall.args[0].method, 'PUT');
@@ -736,7 +741,7 @@
             '/accounts/self/status');
         assert.deepEqual(element._send.lastCall.args[0].body,
             {status: 'OOO'});
-        assert.deepEqual(element._cache['/accounts/self/detail'],
+        assert.deepEqual(element._cache.get('/accounts/self/detail'),
             {status: 'OOO'});
       });
     });
@@ -830,7 +835,7 @@
           Promise.resolve([change_num, file_name, file_contents]));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, file_name, file_contents]));
-      element._cache['/changes/' + change_num + '/edit/' + file_name] = {};
+      element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
       return element.saveChangeEdit(change_num, file_name, file_contents)
           .then(() => {
             assert.isTrue(element._send.calledOnce);
@@ -849,7 +854,7 @@
           Promise.resolve([change_num, message]));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, message]));
-      element._cache['/changes/' + change_num + '/message'] = {};
+      element._cache.set('/changes/' + change_num + '/message', {});
       return element.putChangeCommitMessage(change_num, message).then(() => {
         assert.isTrue(element._send.calledOnce);
         assert.equal(element._send.lastCall.args[0].method, 'PUT');
@@ -1031,7 +1036,8 @@
         const changeNum = 4321;
         element._projectLookup[changeNum] = 'test';
         const params = {foo: 'bar'};
-        const expectedUrl = '/changes/test~4321/detail?foo=bar';
+        const expectedUrl =
+            window.CANONICAL_PATH + '/changes/test~4321/detail?foo=bar';
         sandbox.stub(element._etags, 'getOptions');
         sandbox.stub(element._etags, 'collect');
         return element._getChangeDetail(changeNum, params).then(() => {
@@ -1440,5 +1446,28 @@
       flushAsynchronousOperations();
       assert.isTrue(handler.calledOnce);
     });
+
+    test('saveChangeStarred', async () => {
+      sandbox.stub(element, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      const sendStub =
+          sandbox.stub(element, '_send').returns(Promise.resolve());
+
+      await element.saveChangeStarred(123, true);
+      assert.isTrue(sendStub.calledOnce);
+      assert.deepEqual(sendStub.lastCall.args[0], {
+        method: 'PUT',
+        url: '/accounts/self/starred.changes/test~123',
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+
+      await element.saveChangeStarred(456, false);
+      assert.isTrue(sendStub.calledTwice);
+      assert.deepEqual(sendStub.lastCall.args[0], {
+        method: 'DELETE',
+        url: '/accounts/self/starred.changes/test~456',
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/embed/embed.html b/polygerrit-ui/app/embed/embed.html
index 948916f..1b2f20f 100644
--- a/polygerrit-ui/app/embed/embed.html
+++ b/polygerrit-ui/app/embed/embed.html
@@ -25,7 +25,6 @@
 <link rel="import" href="../elements/core/gr-search-bar/gr-search-bar.html">
 <link rel="import" href="../elements/diff/gr-diff-view/gr-diff-view.html">
 <link rel="import" href="../elements/change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="../elements/change-list/gr-change-list/gr-change-list.html">
-<link rel="import" href="../elements/change-list/gr-create-change-help/gr-create-change-help.html">
 <link rel="import" href="../elements/change-list/gr-dashboard-view/gr-dashboard-view.html">
+<link rel="import" href="../elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html">
 <link rel="import" href="../styles/themes/app-theme.html">
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 0832459..5b9ae15 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -92,6 +92,8 @@
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-dialog/gr-error-dialog_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
+    'core/gr-key-binding-display/gr-key-binding-display_test.html',
+    'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
     'core/gr-reporting/gr-jank-detector_test.html',
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 816dd23..78c8684 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -30,6 +30,7 @@
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
   <meta name="description" content="Gerrit Code Review">{\n}
+  <meta name="referrer" content="never">{\n}
   <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
 
   <script>
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 0997bcb..23d1ccd 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -428,7 +428,7 @@
     """Combine html, js, css files and optionally split into js and html bundles."""
     _bundle_rule(*args, pkg = native.package_name(), **kwargs)
 
-def polygerrit_plugin(name, app, srcs = [], assets = None, **kwargs):
+def polygerrit_plugin(name, app, srcs = [], assets = None, plugin_name = None, **kwargs):
     """Bundles plugin dependencies for deployment.
 
     This rule bundles all Polymer elements and JS dependencies into .html and .js files.
@@ -436,19 +436,39 @@
     Output of this rule is a FileSet with "${name}_fs", with deploy artifacts in "plugins/${name}/static".
 
     Args:
-      name: String, plugin name.
+      name: String, rule name.
       app: String, the main or root source file.
       assets: Fileset, additional files to be used by plugin in runtime, exported to "plugins/${name}/static".
-      srcs: Source files required for combining.
+      plugin_name: String, plugin name. ${name} is used if not provided.
     """
+    if not plugin_name:
+        plugin_name = name
 
-    # Combines all .js and .html files into foo_combined.js and foo_combined.html
-    _bundle_rule(
-        name = name + "_combined",
-        app = app,
-        srcs = srcs if app in srcs else srcs + [app],
-        pkg = native.package_name(),
-        **kwargs
+    html_plugin = app.endswith(".html")
+    srcs = srcs if app in srcs else srcs + [app]
+
+    if html_plugin:
+        # Combines all .js and .html files into foo_combined.js and foo_combined.html
+        _bundle_rule(
+            name = name + "_combined",
+            app = app,
+            srcs = srcs,
+            pkg = native.package_name(),
+            **kwargs
+        )
+        js_srcs = [name + "_combined.js"]
+    else:
+        js_srcs = srcs
+
+    closure_js_library(
+        name = name + "_closure_lib",
+        srcs = js_srcs,
+        convention = "GOOGLE",
+        no_closure_library = True,
+        deps = [
+            "//lib/polymer_externs:polymer_closure",
+            "//polygerrit-ui/app/externs:plugin",
+        ],
     )
 
     closure_js_binary(
@@ -464,37 +484,27 @@
         ],
     )
 
-    closure_js_library(
-        name = name + "_closure_lib",
-        srcs = [name + "_combined.js"],
-        convention = "GOOGLE",
-        no_closure_library = True,
-        deps = [
-            "//lib/polymer_externs:polymer_closure",
-            "//polygerrit-ui/app/externs:plugin",
-        ],
-    )
-
-    native.genrule(
-        name = name + "_rename_html",
-        srcs = [name + "_combined.html"],
-        outs = [name + ".html"],
-        cmd = "sed 's/<script src=\"" + name + "_combined.js\"/<script src=\"" + name + ".js\"/g' $(SRCS) > $(OUTS)",
-        output_to_bindir = True,
-    )
+    if html_plugin:
+        native.genrule(
+            name = name + "_rename_html",
+            srcs = [name + "_combined.html"],
+            outs = [plugin_name + ".html"],
+            cmd = "sed 's/<script src=\"" + name + "_combined.js\"/<script src=\"" + plugin_name + ".js\"/g' $(SRCS) > $(OUTS)",
+            output_to_bindir = True,
+        )
 
     native.genrule(
         name = name + "_rename_js",
         srcs = [name + "_bin.js"],
-        outs = [name + ".js"],
+        outs = [plugin_name + ".js"],
         cmd = "cp $< $@",
         output_to_bindir = True,
     )
 
-    static_files = [
-        name + ".js",
-        name + ".html",
-    ]
+    if html_plugin:
+        static_files = [plugin_name + ".js", plugin_name + ".html"]
+    else:
+        static_files = [plugin_name + ".js"]
 
     if assets:
         nested, direct = [], []
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index cbf7904..c5faa49 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.16-rc0</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -53,6 +53,9 @@
       <name>Logan Hanks</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index d8af152..52b11c1 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.16-rc0</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 789e027..d22c3ee 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.16-rc0</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -53,6 +53,9 @@
       <name>Logan Hanks</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-plugin-gwtui_pom.xml b/tools/maven/gerrit-plugin-gwtui_pom.xml
index a2d3a89..e756352 100644
--- a/tools/maven/gerrit-plugin-gwtui_pom.xml
+++ b/tools/maven/gerrit-plugin-gwtui_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.16-rc0</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
@@ -53,6 +53,9 @@
       <name>Logan Hanks</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 6cbd42a..e6c04e2 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.16-rc0</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -53,6 +53,9 @@
       <name>Logan Hanks</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/version.bzl b/version.bzl
index 8b644fa..20fd8a7 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.16-rc0"
+GERRIT_VERSION = "3.0-SNAPSHOT"