Merge branch 'stable-2.16'

* stable-2.16:
  Upgrade gitiles blame-cache to 0.2-7
  Reload change when vote is deleted
  Fix malformed change weblinks generation
  Don't display user header above project dashboards
  Support any dashboard ref, not just "custom"
  Link to project dashboards instead of custom
  Add 't to add topic' to change view
  Reset loading back to true
  Update .mailmap
  Add --help to GerritLauncher
  Add link to changes to the repo admin page
  Add a "Changes" link for repositories in the repo list.
  Fix display of related changes
  Reply-Dialog: Clarify save button tooltip not sending notification
  Clean up remaining command reference files
  Fix Top Menu Url
  Add support for Documentation search

Change-Id: Ice4287a3c37ffc80fc0d712f2db7a2ced316fac5
diff --git a/BUILD b/BUILD
index d924417..a104dd5 100644
--- a/BUILD
+++ b/BUILD
@@ -2,10 +2,6 @@
 
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:pkg_war.bzl", "pkg_war")
-load(
-    "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
-    "default_java_toolchain",
-)
 
 config_setting(
     name = "java9",
@@ -15,42 +11,12 @@
 )
 
 config_setting(
-    name = "java10",
+    name = "java_next",
     values = {
-        "java_toolchain": ":toolchain_vanilla",
+        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_vanilla",
     },
 )
 
-# TODO(davido): Switch to consuming it from @bazel_tool//tools/jdk:absolute_javabase
-# when new Bazel version is released with this change included:
-# https://github.com/bazelbuild/bazel/issues/6012
-# https://github.com/bazelbuild/bazel/commit/0173bdbf7bdd1874379d4dd3eb70d5321e0f1816
-# As the interim use a hack that works around it by putting the variable reference
-# behind a select
-config_setting(
-    name = "use_absolute_javabase",
-    values = {"define": "USE_ABSOLUTE_JAVABASE=true"},
-)
-
-java_runtime(
-    name = "absolute_javabase",
-    java_home = select({
-        ":use_absolute_javabase": "$(ABSOLUTE_JAVABASE)",
-        "//conditions:default": "",
-    }),
-    visibility = ["//visibility:public"],
-)
-
-# TODO(davido): Switch to consuming it from @bazel_tool//tools/jdk:toolchain_vanilla
-# when my change is included in released Bazel version:
-# https://github.com/bazelbuild/bazel/commit/0bef68e054eccecd690e5d9f46db8a0c4b2d887a
-default_java_toolchain(
-    name = "toolchain_vanilla",
-    forcibly_disable_header_compilation = True,
-    javabuilder = ["@bazel_tools//tools/jdk:VanillaJavaBuilder_deploy.jar"],
-    jvm_opts = [],
-)
-
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
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..aa8609a 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -6,7 +6,7 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|9|10
+* A JDK for Java 8|9|10|11|...
 * Python 2 or 3
 * Node.js
 * link:https://www.bazel.io/versions/master/docs/install.html[Bazel]
@@ -14,27 +14,55 @@
 * zip, unzip
 * gcc
 
-[[Java 10 support]]
-Java 10 is supported through vanilla java toolchain
+[[Java 10 and newer version support]]
+Java 10 (and newer is) supported through vanilla java toolchain
 link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 10, specify vanilla java toolchain and provide
-path to Java 10 home:
+To build Gerrit with Java 10 and newer, specify vanilla java toolchain and
+provide the path to JDK home:
 
 ```
-  $ bazel build --host_javabase=:absolute_javabase \
+  $ bazel build \
     --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
-    --define=USE_ABSOLUTE_JAVABASE=true \
-    --host_java_toolchain=//:toolchain_vanilla \
-    --java_toolchain=//:toolchain_vanilla \
+    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
     :release
 ```
 
-Note that the following options must be added to `container.javaOptions`
-in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 10:
+To run the tests, `--javabase` option must be passed as well, because
+bazel test runs the test using the target javabase:
+
+```
+  $ bazel test \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
+    --javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    //...
+```
+
+To avoid passing all those options on every Bazel build invocation,
+they could be added to ~/.bazelrc resource file:
+
+```
+$ cat << EOF > ~/.bazelrc
+> build --define=ABSOLUTE_JAVABASE=<path-to-java-10>
+> build --javabase=@bazel_tools//tools/jdk:absolute_javabase
+> build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
+> build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+> build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+> EOF
+```
+
+Now, invoking Bazel with just `bazel build :release` would include
+all those options.
+
+Note that the follow option must be added to `container.javaOptions`
+in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 10|11|...:
 
 ```
 [container]
-  javaOptions = --add-modules java.activation
   javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
 ```
 
@@ -51,13 +79,12 @@
       :release
 ```
 
-Note that the following option must be added to `container.javaOptions`
+Note that the follow option must be added to `container.javaOptions`
 in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 9:
 
 ```
 [container]
-  javaOptions = --add-modules java.activation \
-      --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+  javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
 ```
 
 [[build]]
@@ -258,8 +285,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 +306,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 1829c6b..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[
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 3214761..49ab36f 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1358,25 +1358,6 @@
 Ref(ref)::
 The branch for which to check access. This must be given if `perm` is specified.
 
-[[check-access-post]]
-=== Check Access (POST)
-
-This endpoint can also be accessed as a POST request (deprecated). In
-this case, the input for the access checks must be provided in the
-request body inside a link:#access-check-input[AccessCheckInput]
-entity.
-
-.Request
-----
-  POST /projects/MyProject/check.access HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "account": "Kristen.Burns@gerritcodereview.com",
-    "ref": "refs/heads/secret/bla"
-  }
-----
-
 [[index]]
 === Index project
 
@@ -2936,21 +2917,6 @@
 |`message`                   |optional|A clarifying message if `status` is not 200.
 |=========================================
 
-[[access-check-input]]
-=== AccessCheckInput
-The `AccessCheckInput` entity is either an account or
-(account, ref) tuple for which we want to check access.
-
-[options="header",cols="1,^1,5"]
-|=========================================
-|Field Name                  ||Description
-|`account`                   ||The account for which to check access
-|`ref`                       |optional|The refname for which to check
-access
-|`permission`                |optional|The ref permission for which to
-check. This defaults to `read`. If given, it `ref` must be given too.
-|=========================================
-
 [[auto_closeable_changes_check_input]]
 === AutoCloseableChangesCheckInput
 The `AutoCloseableChangesCheckInput` entity contains options for running
diff --git a/WORKSPACE b/WORKSPACE
index 322d93f..c0591b8 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"
@@ -1127,6 +1127,12 @@
     sha1 = "65bd0cacc9c79a21c6ed8e9f588577cd3c2f85b9",
 )
 
+maven_jar(
+    name = "javax-activation",
+    artifact = "javax.activation:activation:1.1.1",
+    sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
+)
+
 load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
 
 # NPM binaries bundled along with their dependencies.
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 496ee5b..0107bae 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -38,6 +38,7 @@
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
@@ -50,11 +51,11 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -252,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;
@@ -262,12 +264,14 @@
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
+  @Inject protected GroupOperations groupOperations;
 
   protected EventRecorder eventRecorder;
   protected GerritServer server;
   protected Project.NameKey project;
   protected RestSession adminRestSession;
   protected RestSession userRestSession;
+  protected RestSession anonymousRestSession;
   protected ReviewDb db;
   protected SshSession adminSshSession;
   protected SshSession userSshSession;
@@ -444,6 +448,7 @@
 
     adminRestSession = new RestSession(server, admin);
     userRestSession = new RestSession(server, user);
+    anonymousRestSession = new RestSession(server, null);
 
     initSsh();
 
@@ -997,7 +1002,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());
@@ -1006,7 +1011,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());
@@ -1015,7 +1020,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());
@@ -1077,7 +1082,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);
@@ -1101,7 +1106,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);
@@ -1119,7 +1124,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();
@@ -1189,27 +1194,7 @@
     return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
   }
 
-  protected String createGroup(String name) throws Exception {
-    return createGroup(name, "Administrators");
-  }
-
-  protected String createGroupWithRealName(String name) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = "Administrators";
-    gApi.groups().create(in);
-    return name;
-  }
-
-  protected String createGroup(String name, String owner) throws Exception {
-    name = name(name);
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = owner;
-    gApi.groups().create(in);
-    return name;
-  }
-
+  @Nullable
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
       Ref r = repo.exactRef(name);
@@ -1221,16 +1206,19 @@
     return getHead(repo, "HEAD");
   }
 
+  @Nullable
   protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       return getHead(repo, branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
     }
   }
 
+  @Nullable
   protected RevCommit getRemoteHead(String project, String branch) throws Exception {
     return getRemoteHead(new Project.NameKey(project), branch);
   }
 
+  @Nullable
   protected RevCommit getRemoteHead() throws Exception {
     return getRemoteHead(project, "master");
   }
@@ -1244,14 +1232,15 @@
   protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
       throws Exception {
     ContributorAgreement ca;
+    String name = autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group";
+    String g = groupOperations.newGroup().name(name).create().get();
+    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));
@@ -1260,6 +1249,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);
@@ -1667,7 +1658,7 @@
 
     private ProjectConfigUpdate(Project.NameKey projectName) throws Exception {
       metaDataUpdate = metaDataUpdateFactory.create(projectName);
-      projectConfig = ProjectConfig.read(metaDataUpdate);
+      projectConfig = projectConfigFactory.read(metaDataUpdate);
     }
 
     public ProjectConfig getConfig() {
@@ -1714,4 +1705,13 @@
     comments.sort(Comparator.comparing(c -> c.id));
     return comments;
   }
+
+  protected List<RelatedChangeAndCommitInfo> getRelated(PatchSet.Id ps) throws Exception {
+    return getRelated(ps.getParentKey(), ps.get());
+  }
+
+  protected List<RelatedChangeAndCommitInfo> getRelated(Change.Id changeId, int ps)
+      throws Exception {
+    return gApi.changes().id(changeId.get()).revision(ps).related().changes;
+  }
 }
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/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 0150e1e..eb27260 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -49,17 +49,21 @@
    * @return API for accessing the revision.
    * @throws RestApiException if an error occurred.
    */
-  RevisionApi current() throws RestApiException;
+  default RevisionApi current() throws RestApiException {
+    return revision("current");
+  }
 
   /**
    * Look up a revision of a change by number.
    *
    * @see #current()
    */
-  RevisionApi revision(int id) throws RestApiException;
+  default RevisionApi revision(int id) throws RestApiException {
+    return revision(Integer.toString(id));
+  }
 
   /**
-   * Look up a revision of a change by commit SHA-1.
+   * Look up a revision of a change by commit SHA-1 or other supported revision string.
    *
    * @see #current()
    */
@@ -79,15 +83,23 @@
    */
   ReviewerApi reviewer(String id) throws RestApiException;
 
-  void abandon() throws RestApiException;
+  default void abandon() throws RestApiException {
+    abandon(new AbandonInput());
+  }
 
   void abandon(AbandonInput in) throws RestApiException;
 
-  void restore() throws RestApiException;
+  default void restore() throws RestApiException {
+    restore(new RestoreInput());
+  }
 
   void restore(RestoreInput in) throws RestApiException;
 
-  void move(String destination) throws RestApiException;
+  default void move(String destination) throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    move(in);
+  }
 
   void move(MoveInput in) throws RestApiException;
 
@@ -132,7 +144,9 @@
    *
    * @see Changes#id(int)
    */
-  ChangeApi revert() throws RestApiException;
+  default ChangeApi revert() throws RestApiException {
+    return revert(new RevertInput());
+  }
 
   /**
    * Create a new change that reverts this change.
@@ -144,10 +158,17 @@
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
-  List<ChangeInfo> submittedTogether() throws RestApiException;
+  default List<ChangeInfo> submittedTogether() throws RestApiException {
+    SubmittedTogetherInfo info =
+        submittedTogether(
+            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
+    return info.changes;
+  }
 
-  SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
-      throws RestApiException;
+  default SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
+      throws RestApiException {
+    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
+  }
 
   SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
@@ -155,10 +176,14 @@
 
   /** Publishes a draft change. */
   @Deprecated
-  void publish() throws RestApiException;
+  default void publish() {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
 
   /** Rebase the current revision of a change using default options. */
-  void rebase() throws RestApiException;
+  default void rebase() throws RestApiException {
+    rebase(new RebaseInput());
+  }
 
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
@@ -172,13 +197,19 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
+  default AddReviewerResult addReviewer(String reviewer) throws RestApiException {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = reviewer;
+    return addReviewer(in);
+  }
 
-  AddReviewerResult addReviewer(String in) throws RestApiException;
+  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
-  SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException;
+  default SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+    return suggestReviewers().withQuery(query);
+  }
 
   ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException;
 
@@ -198,10 +229,16 @@
    *   <li>{@code SKIP_MERGEABLE} is omitted, so the {@code mergeable} bit <em>is</em> set.
    * </ul>
    */
-  ChangeInfo get() throws RestApiException;
+  default ChangeInfo get() throws RestApiException {
+    return get(
+        EnumSet.complementOf(
+            EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_MERGEABLE)));
+  }
 
   /** {@link #get(ListChangesOption...)} with no options included. */
-  ChangeInfo info() throws RestApiException;
+  default ChangeInfo info() throws RestApiException {
+    return get(EnumSet.noneOf(ListChangesOption.class));
+  }
 
   /**
    * Retrieve change edit when exists.
@@ -210,7 +247,9 @@
    *     ChangeEditApi#get()}.
    */
   @Deprecated
-  EditInfo getEdit() throws RestApiException;
+  default EditInfo getEdit() throws RestApiException {
+    return edit().get().orElse(null);
+  }
 
   /**
    * Provides access to an API regarding the change edit of this change.
@@ -221,7 +260,11 @@
   ChangeEditApi edit() throws RestApiException;
 
   /** Create a new patch set with a new commit message. */
-  void setMessage(String message) throws RestApiException;
+  default void setMessage(String message) throws RestApiException {
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = message;
+    setMessage(in);
+  }
 
   /** Create a new patch set with a new commit message. */
   void setMessage(CommitMessageInput in) throws RestApiException;
@@ -347,16 +390,6 @@
     }
 
     @Override
-    public RevisionApi current() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public RevisionApi revision(int id) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ReviewerApi reviewer(String id) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -367,31 +400,16 @@
     }
 
     @Override
-    public void abandon() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void abandon(AbandonInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void restore() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void restore(RestoreInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void move(String destination) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void move(MoveInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -412,27 +430,11 @@
     }
 
     @Override
-    public ChangeApi revert() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ChangeApi revert(RevertInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void publish() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Deprecated
-    @Override
-    public void rebase() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void rebase(RebaseInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -463,51 +465,21 @@
     }
 
     @Override
-    public AddReviewerResult addReviewer(String in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public ChangeInfo get() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ChangeInfo info() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void setMessage(String message) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void setMessage(CommitMessageInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public EditInfo getEdit() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ChangeEditApi edit() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
new file mode 100644
index 0000000..5bf22aa
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -0,0 +1,43 @@
+// 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.extensions.api.changes;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.extensions.common.CommitInfo;
+
+public class RelatedChangeAndCommitInfo {
+  public String project;
+  public String changeId;
+  public CommitInfo commit;
+  public Integer _changeNumber;
+  public Integer _revisionNumber;
+  public Integer _currentRevisionNumber;
+  public String status;
+
+  public RelatedChangeAndCommitInfo() {}
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("project", project)
+        .add("changeId", changeId)
+        .add("commit", commit)
+        .add("_changeNumber", _changeNumber)
+        .add("_revisionNumber", _revisionNumber)
+        .add("_currentRevisionNumber", _currentRevisionNumber)
+        .add("status", status)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java
new file mode 100644
index 0000000..e1e70f3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java
@@ -0,0 +1,21 @@
+// 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.extensions.api.changes;
+
+import java.util.List;
+
+public class RelatedChangesInfo {
+  public List<RelatedChangeAndCommitInfo> changes;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 4658eb3..a6df45f 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CherryPickChangeInfo;
@@ -34,7 +35,9 @@
 
 public interface RevisionApi {
   @Deprecated
-  void delete() throws RestApiException;
+  default void delete() {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
 
   String description() throws RestApiException;
 
@@ -42,22 +45,32 @@
 
   ReviewResult review(ReviewInput in) throws RestApiException;
 
-  void submit() throws RestApiException;
+  default void submit() throws RestApiException {
+    SubmitInput in = new SubmitInput();
+    submit(in);
+  }
 
   void submit(SubmitInput in) throws RestApiException;
 
-  BinaryResult submitPreview() throws RestApiException;
+  default BinaryResult submitPreview() throws RestApiException {
+    return submitPreview("zip");
+  }
 
   BinaryResult submitPreview(String format) throws RestApiException;
 
   @Deprecated
-  void publish() throws RestApiException;
+  default void publish() {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
 
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
   CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
 
-  ChangeApi rebase() throws RestApiException;
+  default ChangeApi rebase() throws RestApiException {
+    RebaseInput in = new RebaseInput();
+    return rebase(in);
+  }
 
   ChangeApi rebase(RebaseInput in) throws RestApiException;
 
@@ -69,9 +82,11 @@
 
   Set<String> reviewed() throws RestApiException;
 
-  Map<String, FileInfo> files() throws RestApiException;
+  default Map<String, FileInfo> files() throws RestApiException {
+    return files(null);
+  }
 
-  Map<String, FileInfo> files(String base) throws RestApiException;
+  Map<String, FileInfo> files(@Nullable String base) throws RestApiException;
 
   Map<String, FileInfo> files(int parentNum) throws RestApiException;
 
@@ -133,6 +148,8 @@
 
   MergeListRequest getMergeList() throws RestApiException;
 
+  RelatedChangesInfo related() throws RestApiException;
+
   abstract class MergeListRequest {
     private boolean addLinks;
     private int uninterestingParent = 1;
@@ -163,33 +180,16 @@
    * interface.
    */
   class NotImplemented implements RevisionApi {
-    @Deprecated
-    @Override
-    public void delete() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
     @Override
     public ReviewResult review(ReviewInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void submit() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void submit(SubmitInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
-    @Deprecated
-    @Override
-    public void publish() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
     @Override
     public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
       throw new NotImplementedException();
@@ -201,11 +201,6 @@
     }
 
     @Override
-    public ChangeApi rebase() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ChangeApi rebase(RebaseInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -251,11 +246,6 @@
     }
 
     @Override
-    public Map<String, FileInfo> files() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public List<String> queryFiles(String query) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -346,11 +336,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public BinaryResult submitPreview(String format) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -371,6 +356,11 @@
     }
 
     @Override
+    public RelatedChangesInfo related() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(String description) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/RefInfo.java b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
index c573600..d5695fd 100644
--- a/java/com/google/gerrit/extensions/api/projects/RefInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
@@ -14,8 +14,15 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.base.MoreObjects;
+
 public class RefInfo {
   public String ref;
   public String revision;
   public Boolean canDelete;
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("ref", ref).add("revision", revision).toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 945c239..9a739ef 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -46,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 213b366..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,17 +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);
-    sb.append(", parents=").append(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);
-    if (webLinks != null) {
-      sb.append(", webLinks=").append(webLinks);
+    ToStringHelper helper = MoreObjects.toStringHelper(this).addValue(commit);
+    if (parents != null) {
+      helper.add("parents", parents.stream().map(p -> p.commit).collect(joining(", ")));
     }
-    return sb.append('}').toString();
+    helper
+        .add("author", author)
+        .add("committer", committer)
+        .add("subject", subject)
+        .add("message", message);
+    if (webLinks != null) {
+      helper.add("webLinks", webLinks);
+    }
+    return helper.toString();
   }
 }
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 1dd176a..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);
 
@@ -164,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() {
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 6a43a1d..9e8592a 100644
--- a/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -88,6 +88,7 @@
   ReviewProjectAccess(
       PermissionBackend permissionBackend,
       GroupBackend groupBackend,
+      ProjectConfig.Factory projectConfigFactory,
       MetaDataUpdate.User metaDataUpdateFactory,
       ReviewDb db,
       Provider<PostReviewers> reviewersProvider,
@@ -108,6 +109,7 @@
       @Nullable @Assisted String message) {
     super(
         groupBackend,
+        projectConfigFactory,
         metaDataUpdateFactory,
         allProjects,
         setParent,
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/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index b7ec2be..dab9d7e 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -45,6 +45,7 @@
   protected static final String P_PROTOCOL = "Version";
   protected static final String P_STATUS = "Status";
   protected static final String P_CONTENT_LENGTH = "Content-Length";
+  protected static final String P_LATENCY = "Latency";
   protected static final String P_REFERER = "Referer";
   protected static final String P_USER_AGENT = "User-Agent";
 
@@ -94,6 +95,7 @@
     set(event, P_PROTOCOL, req.getProtocol());
     set(event, P_STATUS, rsp.getStatus());
     set(event, P_CONTENT_LENGTH, rsp.getContentCount());
+    set(event, P_LATENCY, System.currentTimeMillis() - req.getTimeStamp());
     set(event, P_REFERER, req.getHeader("Referer"));
     set(event, P_USER_AGENT, req.getHeader("User-Agent"));
 
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index 2eea88d..bd7d998 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -67,6 +67,9 @@
     opt(buf, event, HttpLog.P_CONTENT_LENGTH);
 
     buf.append(' ');
+    opt(buf, event, HttpLog.P_LATENCY);
+
+    buf.append(' ');
     dq_opt(buf, event, HttpLog.P_REFERER);
 
     buf.append(' ');
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/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 358a3a8..0f731ed 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
@@ -256,16 +255,6 @@
   }
 
   @Override
-  public RevisionApi current() throws RestApiException {
-    return revision("current");
-  }
-
-  @Override
-  public RevisionApi revision(int id) throws RestApiException {
-    return revision(String.valueOf(id));
-  }
-
-  @Override
   public RevisionApi revision(String id) throws RestApiException {
     try {
       return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
@@ -284,11 +273,6 @@
   }
 
   @Override
-  public void abandon() throws RestApiException {
-    abandon(new AbandonInput());
-  }
-
-  @Override
   public void abandon(AbandonInput in) throws RestApiException {
     try {
       abandon.apply(change, in);
@@ -298,11 +282,6 @@
   }
 
   @Override
-  public void restore() throws RestApiException {
-    restore(new RestoreInput());
-  }
-
-  @Override
   public void restore(RestoreInput in) throws RestApiException {
     try {
       restore.apply(change, in);
@@ -312,13 +291,6 @@
   }
 
   @Override
-  public void move(String destination) throws RestApiException {
-    MoveInput in = new MoveInput();
-    in.destinationBranch = destination;
-    move(in);
-  }
-
-  @Override
   public void move(MoveInput in) throws RestApiException {
     try {
       move.apply(change, in);
@@ -360,11 +332,6 @@
   }
 
   @Override
-  public ChangeApi revert() throws RestApiException {
-    return revert(new RevertInput());
-  }
-
-  @Override
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
       return changeApi.id(revert.apply(change, in)._number);
@@ -383,20 +350,6 @@
   }
 
   @Override
-  public List<ChangeInfo> submittedTogether() throws RestApiException {
-    SubmittedTogetherInfo info =
-        submittedTogether(
-            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
-    return info.changes;
-  }
-
-  @Override
-  public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
-      throws RestApiException {
-    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
-  }
-
-  @Override
   public SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
       throws RestApiException {
@@ -411,17 +364,6 @@
     }
   }
 
-  @Deprecated
-  @Override
-  public void publish() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public void rebase() throws RestApiException {
-    rebase(new RebaseInput());
-  }
-
   @Override
   public void rebase(RebaseInput in) throws RestApiException {
     try {
@@ -466,13 +408,6 @@
   }
 
   @Override
-  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = reviewer;
-    return addReviewer(in);
-  }
-
-  @Override
   public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
     try {
       return postReviewers.apply(change, in);
@@ -491,11 +426,6 @@
     };
   }
 
-  @Override
-  public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
-    return suggestReviewers().withQuery(query);
-  }
-
   private List<SuggestedReviewerInfo> suggestReviewers(SuggestedReviewersRequest r)
       throws RestApiException {
     try {
@@ -517,30 +447,11 @@
   }
 
   @Override
-  public ChangeInfo get() throws RestApiException {
-    return get(
-        EnumSet.complementOf(
-            EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_MERGEABLE)));
-  }
-
-  @Override
-  public EditInfo getEdit() throws RestApiException {
-    return edit().get().orElse(null);
-  }
-
-  @Override
   public ChangeEditApi edit() throws RestApiException {
     return changeEditApi.create(change);
   }
 
   @Override
-  public void setMessage(String msg) throws RestApiException {
-    CommitMessageInput in = new CommitMessageInput();
-    in.message = msg;
-    setMessage(in);
-  }
-
-  @Override
   public void setMessage(CommitMessageInput in) throws RestApiException {
     try {
       putMessage.apply(change, in);
@@ -550,11 +461,6 @@
   }
 
   @Override
-  public ChangeInfo info() throws RestApiException {
-    return get(EnumSet.noneOf(ListChangesOption.class));
-  }
-
-  @Override
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
       postHashtags.apply(change, input);
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 33f211d..f8a2ecb 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -26,6 +27,7 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -64,6 +66,7 @@
 import com.google.gerrit.server.restapi.change.GetDescription;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
+import com.google.gerrit.server.restapi.change.GetRelated;
 import com.google.gerrit.server.restapi.change.GetRevisionActions;
 import com.google.gerrit.server.restapi.change.ListRevisionComments;
 import com.google.gerrit.server.restapi.change.ListRevisionDrafts;
@@ -129,6 +132,7 @@
   private final TestSubmitType.Get getSubmitType;
   private final Provider<TestSubmitRule> testSubmitRule;
   private final Provider<GetMergeList> getMergeList;
+  private final GetRelated getRelated;
   private final PutDescription putDescription;
   private final GetDescription getDescription;
 
@@ -169,6 +173,7 @@
       TestSubmitType.Get getSubmitType,
       Provider<TestSubmitRule> testSubmitRule,
       Provider<GetMergeList> getMergeList,
+      GetRelated getRelated,
       PutDescription putDescription,
       GetDescription getDescription,
       @Assisted RevisionResource r) {
@@ -207,6 +212,7 @@
     this.getSubmitType = getSubmitType;
     this.testSubmitRule = testSubmitRule;
     this.getMergeList = getMergeList;
+    this.getRelated = getRelated;
     this.putDescription = putDescription;
     this.getDescription = getDescription;
     this.revision = r;
@@ -222,12 +228,6 @@
   }
 
   @Override
-  public void submit() throws RestApiException {
-    SubmitInput in = new SubmitInput();
-    submit(in);
-  }
-
-  @Override
   public void submit(SubmitInput in) throws RestApiException {
     try {
       submit.apply(revision, in);
@@ -237,11 +237,6 @@
   }
 
   @Override
-  public BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  @Override
   public BinaryResult submitPreview(String format) throws RestApiException {
     try {
       submitPreview.setFormat(format);
@@ -252,22 +247,6 @@
   }
 
   @Override
-  public void publish() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public ChangeApi rebase() throws RestApiException {
-    RebaseInput in = new RebaseInput();
-    return rebase(in);
-  }
-
-  @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebase.apply(revision, in)._number);
@@ -361,17 +340,7 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public Map<String, FileInfo> files() throws RestApiException {
-    try {
-      return (Map<String, FileInfo>) listFiles.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve files", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public Map<String, FileInfo> files(String base) throws RestApiException {
+  public Map<String, FileInfo> files(@Nullable String base) throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
     } catch (Exception e) {
@@ -590,6 +559,15 @@
   }
 
   @Override
+  public RelatedChangesInfo related() throws RestApiException {
+    try {
+      return getRelated.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get related changes", e);
+    }
+  }
+
+  @Override
   public void description(String description) throws RestApiException {
     DescriptionInput in = new DescriptionInput();
     in.description = description;
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/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 1f216f0..dac8fec 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -20,12 +20,14 @@
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -199,8 +201,14 @@
               + " was rebased");
     }
 
-    if (base != null) {
-      patchSetInserter.setGroups(base.patchSet().getGroups());
+    if (base != null && base.notes().getChange().getStatus() != Change.Status.MERGED) {
+      if (base.notes().getChange().getStatus() != Change.Status.MERGED) {
+        // Add to end of relation chain for open base change.
+        patchSetInserter.setGroups(base.patchSet().getGroups());
+      } else {
+        // If the base is merged, start a new relation chain.
+        patchSetInserter.setGroups(GroupCollector.getDefaultGroups(rebasedCommit));
+      }
     }
     patchSetInserter.updateRepo(ctx);
   }
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/DefaultAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
index ef5e65b..be8fcdb 100644
--- a/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import java.io.IOException;
+import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -28,7 +31,6 @@
  * implements {@link org.eclipse.jgit.transport.AdvertiseRefsHook}.
  */
 public class DefaultAdvertiseRefsHook extends AbstractAdvertiseRefsHook {
-
   private final PermissionBackend.ForProject perm;
   private final PermissionBackend.RefFilterOptions opts;
 
@@ -42,9 +44,24 @@
   protected Map<String, Ref> getAdvertisedRefs(Repository repo, RevWalk revWalk)
       throws ServiceMayNotContinueException {
     try {
-      return perm.filter(repo.getAllRefs(), repo, opts);
-    } catch (PermissionBackendException e) {
-      throw new ServiceMayNotContinueException(e);
+      Map<String, Ref> refs;
+      List<String> prefixes = opts.prefixes();
+      if (prefixes.isEmpty() || prefixes.get(0).isEmpty()) {
+        refs = repo.getAllRefs();
+      } else {
+        ImmutableMap.Builder<String, Ref> b = new ImmutableMap.Builder<>();
+        for (String prefix : prefixes) {
+          for (Ref ref : repo.getRefDatabase().getRefsByPrefix(prefix)) {
+            b.put(ref.getName(), ref);
+          }
+        }
+        refs = b.build();
+      }
+      return perm.filter(refs, repo, opts);
+    } catch (IOException | PermissionBackendException e) {
+      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+      ex.initCause(e);
+      throw ex;
     }
   }
 }
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/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index db3c961..bea760c 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
@@ -40,6 +41,7 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -367,12 +369,19 @@
     /** Separately add reachable tags. */
     public abstract boolean filterTagsSeparately();
 
+    /**
+     * Select only refs with names matching prefixes per {@link
+     * org.eclipse.jgit.lib.RefDatabase#getRefsByPrefix}.
+     */
+    public abstract ImmutableList<String> prefixes();
+
     public abstract Builder toBuilder();
 
     public static Builder builder() {
       return new AutoValue_PermissionBackend_RefFilterOptions.Builder()
           .setFilterMeta(false)
-          .setFilterTagsSeparately(false);
+          .setFilterTagsSeparately(false)
+          .setPrefixes(Collections.singletonList(""));
     }
 
     @AutoValue.Builder
@@ -381,6 +390,8 @@
 
       public abstract Builder setFilterTagsSeparately(boolean val);
 
+      public abstract Builder setPrefixes(List<String> prefixes);
+
       public abstract RefFilterOptions build();
     }
 
diff --git a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
index afffbef..2268c07 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
@@ -103,8 +103,9 @@
    *
    * <p>Should only be used in exceptional cases to get direct access to the extension
    * implementation. If possible the extension should be invoked through {@link
-   * #run(ExtensionImplConsumer)}, {@link #run(ExtensionImplConsumer, Class)}, {@link
-   * #call(ExtensionImplFunction)} and {@link #call(CheckedExtensionImplFunction, Class)}.
+   * #run(PluginContext.ExtensionImplConsumer)}, {@link #run(PluginContext.ExtensionImplConsumer,
+   * java.lang.Class)}, {@link #call(PluginContext.ExtensionImplFunction)} and {@link
+   * #call(PluginContext.CheckedExtensionImplFunction, java.lang.Class)}.
    *
    * @return the implementation of this extension
    */
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/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index a68bd84..df31c19 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -49,6 +49,7 @@
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
     submitType = SubmitType.MERGE_IF_NECESSARY;
+    rejectEmptyCommit = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getProject() {
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/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/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 1bb6bf2..b374fdc 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -18,6 +18,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -332,7 +333,7 @@
       return this;
     }
 
-    public ListFiles setBase(String base) {
+    public ListFiles setBase(@Nullable String base) {
       this.base = base;
       return this;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 3313136..30fbf39 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -17,9 +17,10 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.MoreObjects;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.index.IndexConfig;
@@ -70,15 +71,15 @@
   }
 
   @Override
-  public RelatedInfo apply(RevisionResource rsrc)
+  public RelatedChangesInfo apply(RevisionResource rsrc)
       throws RepositoryNotFoundException, IOException, OrmException, NoSuchProjectException,
           PermissionBackendException {
-    RelatedInfo relatedInfo = new RelatedInfo();
-    relatedInfo.changes = getRelated(rsrc);
-    return relatedInfo;
+    RelatedChangesInfo relatedChangesInfo = new RelatedChangesInfo();
+    relatedChangesInfo.changes = getRelated(rsrc);
+    return relatedChangesInfo;
   }
 
-  private List<ChangeAndCommit> getRelated(RevisionResource rsrc)
+  private List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
       throws OrmException, IOException, PermissionBackendException {
     Set<String> groups = getAllGroups(rsrc.getNotes(), db.get(), psUtil);
     if (groups.isEmpty()) {
@@ -94,7 +95,7 @@
     if (cds.size() == 1 && cds.get(0).getId().equals(rsrc.getChange().getId())) {
       return Collections.emptyList();
     }
-    List<ChangeAndCommit> result = new ArrayList<>(cds.size());
+    List<RelatedChangeAndCommitInfo> result = new ArrayList<>(cds.size());
 
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
@@ -111,11 +112,11 @@
       } else {
         commit = d.commit();
       }
-      result.add(new ChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
+      result.add(newChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
     }
 
     if (result.size() == 1) {
-      ChangeAndCommit r = result.get(0);
+      RelatedChangeAndCommitInfo r = result.get(0);
       if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
         return Collections.emptyList();
       }
@@ -143,69 +144,30 @@
     }
   }
 
-  public static class RelatedInfo {
-    public List<ChangeAndCommit> changes;
-  }
+  static RelatedChangeAndCommitInfo newChangeAndCommit(
+      Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+    RelatedChangeAndCommitInfo info = new RelatedChangeAndCommitInfo();
+    info.project = project.get();
 
-  public static class ChangeAndCommit {
-    public String project;
-    public String changeId;
-    public CommitInfo commit;
-    public Integer _changeNumber;
-    public Integer _revisionNumber;
-    public Integer _currentRevisionNumber;
-    public String status;
-
-    public ChangeAndCommit() {}
-
-    ChangeAndCommit(
-        Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
-      this.project = project.get();
-
-      if (change != null) {
-        changeId = change.getKey().get();
-        _changeNumber = change.getChangeId();
-        _revisionNumber = ps != null ? ps.getPatchSetId() : null;
-        PatchSet.Id curr = change.currentPatchSetId();
-        _currentRevisionNumber = curr != null ? curr.get() : null;
-        status = change.getStatus().asChangeStatus().toString();
-      }
-
-      commit = new CommitInfo();
-      commit.commit = c.name();
-      commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
-      for (int i = 0; i < c.getParentCount(); i++) {
-        CommitInfo p = new CommitInfo();
-        p.commit = c.getParent(i).name();
-        commit.parents.add(p);
-      }
-      commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
-      commit.subject = c.getShortMessage();
+    if (change != null) {
+      info.changeId = change.getKey().get();
+      info._changeNumber = change.getChangeId();
+      info._revisionNumber = ps != null ? ps.getPatchSetId() : null;
+      PatchSet.Id curr = change.currentPatchSetId();
+      info._currentRevisionNumber = curr != null ? curr.get() : null;
+      info.status = change.getStatus().asChangeStatus().toString();
     }
 
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("project", project)
-          .add("changeId", changeId)
-          .add("commit", toString(commit))
-          .add("_changeNumber", _changeNumber)
-          .add("_revisionNumber", _revisionNumber)
-          .add("_currentRevisionNumber", _currentRevisionNumber)
-          .add("status", status)
-          .toString();
+    info.commit = new CommitInfo();
+    info.commit.commit = c.name();
+    info.commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
+    for (int i = 0; i < c.getParentCount(); i++) {
+      CommitInfo p = new CommitInfo();
+      p.commit = c.getParent(i).name();
+      info.commit.parents.add(p);
     }
-
-    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();
-    }
+    info.commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
+    info.commit.subject = c.getShortMessage();
+    return info;
   }
 }
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/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 2e2f565..eed0896 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -161,7 +161,8 @@
 
     Base base = rebaseUtil.parseBase(rsrc, str);
     if (base == null) {
-      throw new ResourceConflictException("base revision is missing: " + str);
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + str);
     }
     PatchSet.Id baseId = base.patchSet().getId();
     if (change.getId().equals(baseId.getParentKey())) {
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..60a24d8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -109,12 +109,13 @@
   private final MetaDataUpdate.User metaDataUpdateFactory;
   private final GitReferenceUpdated referenceUpdated;
   private final RepositoryConfig repositoryCfg;
-  private final PersonIdent serverIdent;
+  private final Provider<PersonIdent> serverIdent;
   private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
   private final PluginItemContext<ProjectNameLockManager> lockManager;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   CreateProject(
@@ -130,12 +131,13 @@
       MetaDataUpdate.User metaDataUpdateFactory,
       GitReferenceUpdated referenceUpdated,
       RepositoryConfig repositoryCfg,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<IdentifiedUser> identifiedUser,
       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);
@@ -354,7 +357,7 @@
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
-      cb.setCommitter(serverIdent);
+      cb.setCommitter(serverIdent.get());
       cb.setMessage("Initial empty repository\n");
 
       ObjectId id = oi.insert(cb);
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/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 8c8ab49..de5661d 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -55,7 +55,6 @@
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    post(PROJECT_KIND, "check.access").to(CheckAccess.class);
     get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
 
     post(PROJECT_KIND, "check").to(Check.class);
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/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 5e46a03..9886db5 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -97,6 +98,7 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -233,6 +235,8 @@
 
   @Inject private AccountManager accountManager;
 
+  @Inject protected GroupOperations groupOperations;
+
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
@@ -2208,7 +2212,9 @@
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
     accountOperations.newAccount().username(username).create();
-    String group = createGroup("group");
+    AccountGroup.UUID groupID = groupOperations.newGroup().name("group").create();
+    String group = groupOperations.group(groupID).get().name();
+
     gApi.groups().id(group).addMembers(username);
 
     List<GroupInfo> allGroups = gApi.accounts().id(username).getGroups();
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/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 7063b27..744d1e9 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -68,6 +68,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
@@ -80,6 +81,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
@@ -124,6 +126,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;
@@ -139,8 +142,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;
@@ -185,6 +192,11 @@
 
   @Inject private AccountOperations accountOperations;
 
+  @Inject private ChangeIndexCollection changeIndexCollection;
+  @Inject private IndexConfig indexConfig;
+
+  @Inject protected GroupOperations groupOperations;
+
   private ChangeIndexedCounter changeIndexedCounter;
   private RegistrationHandle changeIndexedCounterHandle;
 
@@ -943,13 +955,77 @@
     RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
     assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
 
+    Change.Id id1 = r1.getChange().getId();
     RebaseInput in = new RebaseInput();
-    in.base = Integer.toString(r1.getChange().getId().get());
+    in.base = id1.toString();
     gApi.changes().id(r2.getChangeId()).rebase(in);
 
+    Change.Id id2 = r2.getChange().getId();
     ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
     ri2 = ci2.revisions.get(ci2.currentRevision);
     assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+    List<RelatedChangeAndCommitInfo> related = getRelated(id2, ri2._number);
+    assertThat(related).hasSize(2);
+    assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+    assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+    assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+    assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseOnClosedChange() throws Exception {
+    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+    PushOneCommit.Result r1 = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+    // Submit first change.
+    Change.Id id1 = r1.getChange().getId();
+    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id1.get()).current().submit();
+
+    // Rebase second change on first change.
+    RebaseInput in = new RebaseInput();
+    in.base = id1.toString();
+    gApi.changes().id(r2.getChangeId()).rebase(in);
+
+    Change.Id id2 = r2.getChange().getId();
+    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    ri2 = ci2.revisions.get(ci2.currentRevision);
+    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+    assertThat(getRelated(id2, ri2._number)).isEmpty();
+  }
+
+  @Test
+  public void rebaseFromRelationChainToClosedChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    testRepo.reset("HEAD~1");
+
+    createChange();
+    PushOneCommit.Result r3 = createChange();
+
+    // Submit first change.
+    Change.Id id1 = r1.getChange().getId();
+    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id1.get()).current().submit();
+
+    // Rebase third change on first change.
+    RebaseInput in = new RebaseInput();
+    in.base = id1.toString();
+    gApi.changes().id(r3.getChangeId()).rebase(in);
+
+    Change.Id id3 = r3.getChange().getId();
+    ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
+    assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+    assertThat(getRelated(id3, ri3._number)).isEmpty();
   }
 
   @Test
@@ -1244,6 +1320,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);
@@ -1602,7 +1695,7 @@
     PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
-    gApi.accounts().create(username).setActive(false);
+    accountOperations.newAccount().username(username).inactive().create();
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
@@ -1624,7 +1717,7 @@
     PushOneCommit.Result result = createChange();
 
     String username = "user@domain.com";
-    gApi.accounts().create(username).setActive(false);
+    accountOperations.newAccount().username(username).inactive().create();
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
@@ -1733,7 +1826,7 @@
             .preferredEmail(email)
             .fullname(fullname)
             .create();
-    String testGroup = createGroupWithRealName("ab");
+    String testGroup = groupOperations.newGroup().name("ab").create().get();
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
     groupApi.addMembers(user.fullName);
@@ -1794,7 +1887,7 @@
             .fullname(myGroupUserFullname)
             .create();
 
-    String testGroup = createGroupWithRealName("kobe");
+    String testGroup = groupOperations.newGroup().name("kobe").create().get();
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
     groupApi.addMembers(myGroupUserFullname);
@@ -2006,7 +2099,7 @@
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
 
-    gApi.accounts().id(admin.id.get()).setStatus("new status");
+    accountOperations.account(admin.id).forUpdate().status("new status").update();
     rsrc = parseResource(r);
     assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index 87a566e..491cb3a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.RefRename;
@@ -47,6 +49,8 @@
 @Sandboxed
 @NoHttpd
 public class GroupsConsistencyIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
   private GroupInfo gAdmin;
   private GroupInfo g1;
   private GroupInfo g2;
@@ -57,8 +61,8 @@
   public void basicSetup() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
 
-    String name1 = createGroup("g1");
-    String name2 = createGroup("g2");
+    String name1 = groupOperations.newGroup().name("g1").create().get();
+    String name2 = groupOperations.newGroup().name("g2").create().get();
 
     gApi.groups().id(name1).addMembers(user.fullName);
     gApi.groups().id(name2).addMembers(admin.fullName);
@@ -218,7 +222,7 @@
   @Test
   public void cyclicSubgroup() throws Exception {
     updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", g1.id + "\n");
-    assertWarning("cyclic");
+    assertWarning("cycle");
   }
 
   private void assertError(String msg) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index ade0f3c..4a4ee2a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -144,6 +144,23 @@
     }
   }
 
+  // Creates a group, but with uniquified name.
+  protected String createGroup(String name) throws Exception {
+    // TODO(hanwen): rewrite this test in terms of UUID. This requires redoing the assertion helpers
+    // too.
+    AccountGroup.UUID g = groupOperations.newGroup().ownerGroupUuid(adminGroupUuid()).create();
+    return groupRef(g).getName();
+  }
+
+  protected String createGroup(String name, String owner) throws Exception {
+    name = name(name);
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = owner;
+    gApi.groups().create(in);
+    return name;
+  }
+
   @Override
   protected ProjectResetter.Config resetProjects() {
     // Don't reset All-Users since deleting users makes groups inconsistent (e.g. groups would
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/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/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
index 6563de3..ca8d3ce 100644
--- a/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
@@ -60,7 +60,6 @@
           RestCall.post("/projects/%s/access"),
           RestCall.put("/projects/%s/access:review"),
           RestCall.get("/projects/%s/check.access"),
-          RestCall.post("/projects/%s/check.access"),
           RestCall.put("/projects/%s/ban"),
           RestCall.get("/projects/%s/statistics.git"),
           RestCall.post("/projects/%s/index"),
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/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index d1f4d84..6a9a27c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
+import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -62,12 +64,15 @@
 import org.junit.Test;
 
 public class ChangeReviewersIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
+
   @Test
   public void addGroupAsReviewer() throws Exception {
     // Set up two groups, one that is too large too add as reviewer, and one
     // that is too large to add without confirmation.
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
+    String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
+    String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
 
     int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
     int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
@@ -170,7 +175,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = createGroup("cc1");
+    in.reviewer = groupOperations.newGroup().name("cc1").create().get();
     in.state = CC;
     gApi.groups()
         .id(in.reviewer)
@@ -209,7 +214,7 @@
     result = addReviewer(changeId, reviewer.username);
     assertThat(result.error).isNull();
     sender.clear();
-    in.reviewer = createGroup("cc2");
+    in.reviewer = groupOperations.newGroup().name("cc2").create().get();
     gApi.groups().id(in.reviewer).addMembers(usernames.toArray(new String[usernames.size()]));
     gApi.groups().id(in.reviewer).addMembers(reviewer.username);
     result = addReviewer(changeId, in);
@@ -479,8 +484,8 @@
       usernames.add(u.username);
     }
 
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
+    String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
+    String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
     gApi.groups().id(largeGroup).addMembers(usernames.toArray(new String[largeGroupSize]));
     gApi.groups()
         .id(mediumGroup)
@@ -622,8 +627,8 @@
         accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
     TestAccount user3 =
         accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3");
-    String group1 = createGroup("group1");
-    String group2 = createGroup("group2");
+    String group1 = groupOperations.newGroup().name("group1").create().get();
+    String group2 = groupOperations.newGroup().name("group2").create().get();
     gApi.groups().id(group1).addMembers(user1.username, user2.username);
     gApi.groups().id(group2).addMembers(user2.username, user3.username);
 
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/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 6555fe8..bb114e7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -20,17 +20,22 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
+
   @Test
   public void indexChange() throws Exception {
     String changeId = createChange().getChangeId();
@@ -48,7 +53,8 @@
   public void indexChangeAfterOwnerLosesVisibility() throws Exception {
     // Create a test group with 2 users as members
     TestAccount user2 = accountCreator.user2();
-    String group = createGroup("test");
+    AccountGroup.UUID groupId = groupOperations.newGroup().name("test").create();
+    String group = groupOperations.group(groupId).get().name();
     gApi.groups().id(group).addMembers("admin", "user", user2.username);
 
     // Create a project and restrict its visibility to the group
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/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 5d3b223..8e8aeac 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -24,9 +24,10 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
@@ -35,8 +36,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.GetRelated;
-import com.google.gerrit.server.restapi.change.GetRelated.ChangeAndCommit;
-import com.google.gerrit.server.restapi.change.GetRelated.RelatedInfo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -56,6 +55,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
+@NoHttpd
 public class GetRelatedIT extends AbstractDaemonTest {
   private static final int MAX_TERMS = 10;
 
@@ -577,17 +577,6 @@
     assertRelated(cd.change().currentPatchSetId());
   }
 
-  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
-    return getRelated(ps.getParentKey(), ps.get());
-  }
-
-  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps) throws Exception {
-    String url = String.format("/changes/%d/revisions/%d/related", changeId.get(), ps);
-    RestResponse r = adminRestSession.get(url);
-    r.assertOK();
-    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
-  }
-
   private RevCommit parseBody(RevCommit c) throws Exception {
     testRepo.getRevWalk().parseBody(c);
     return c;
@@ -601,9 +590,9 @@
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
-  private ChangeAndCommit changeAndCommit(
+  private RelatedChangeAndCommitInfo changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
-    ChangeAndCommit result = new ChangeAndCommit();
+    RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
     result._changeNumber = psId.getParentKey().get();
     result.commit = new CommitInfo();
@@ -631,13 +620,14 @@
     }
   }
 
-  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected) throws Exception {
-    List<ChangeAndCommit> actual = getRelated(psId);
+  private void assertRelated(PatchSet.Id psId, RelatedChangeAndCommitInfo... expected)
+      throws Exception {
+    List<RelatedChangeAndCommitInfo> actual = getRelated(psId);
     assertThat(actual).named("related to " + psId).hasSize(expected.length);
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
-      ChangeAndCommit a = actual.get(i);
-      ChangeAndCommit e = expected[i];
+      RelatedChangeAndCommitInfo a = actual.get(i);
+      RelatedChangeAndCommitInfo e = expected[i];
       assertThat(a.project).named("project of " + name).isEqualTo(e.project);
       assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
       // Don't bother checking changeId; assume _changeNumber is sufficient.
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 9ff2c05..b8380f5 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -33,6 +34,7 @@
 
 public class MailProcessorIT extends AbstractMailIT {
   @Inject private MailProcessor mailProcessor;
+  @Inject private AccountOperations accountOperations;
 
   @Test
   public void parseAndPersistChangeMessage() throws Exception {
@@ -163,16 +165,13 @@
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     // Set account state to inactive
-    gApi.accounts().id("user").setActive(false);
+    accountOperations.account(user.id).forUpdate().inactive().update();
 
     mailProcessor.process(b.build());
     comments = gApi.changes().id(changeId).current().commentsAsList();
 
     // Check that comment size has not changed
     assertThat(comments).hasSize(2);
-
-    // Reset
-    gApi.accounts().id("user").setActive(true);
   }
 
   @Test
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/greenmail/BUILD b/lib/greenmail/BUILD
index 55eb9f3..b09f27b 100644
--- a/lib/greenmail/BUILD
+++ b/lib/greenmail/BUILD
@@ -1,8 +1,23 @@
 package(default_visibility = ["//visibility:public"])
 
+POST_JDK8_DEPS = [":javax-activation"]
+
+java_library(
+    name = "javax-activation",
+    testonly = 1,
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    exports = ["@javax-activation//jar"],
+)
+
 java_library(
     name = "greenmail",
+    testonly = 1,
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = ["@greenmail//jar"],
+    runtime_deps = select({
+        "//:java9": POST_JDK8_DEPS,
+        "//:java_next": POST_JDK8_DEPS,
+        "//conditions:default": [],
+    }),
 )
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 d1fdf2f..af982cf 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
@@ -165,6 +165,7 @@
     PREV_FILE: 'PREV_FILE',
     NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
     PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
+    NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
     CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
     CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
     OPEN_FILE: 'OPEN_FILE',
@@ -255,6 +256,8 @@
       'Mark/unmark file as reviewed');
   _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
       'Toggle unified/side-by-side diff');
+  _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
+      'Mark file as reviewed and go to next unreviewed file');
 
   _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
   _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index ad12a44..987b63d 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -55,7 +55,7 @@
           padding: 0 .15em;
         }
       }
-      .hideBranch {
+      .hide {
         display: none;
       }
     </style>
@@ -108,7 +108,7 @@
             </iron-autogrow-textarea>
           </span>
         </section>
-        <section>
+        <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
           <label
               class="title"
               for="privateChangeCheckBox">Private change</label>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 826a6dc..8e15755 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -44,6 +44,7 @@
         notify: true,
         value: false,
       },
+      _privateChangesEnabled: Boolean,
     },
 
     behaviors: [
@@ -52,10 +53,23 @@
     ],
 
     attached() {
-      if (!this.repoName) { return; }
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
-        this.privateByDefault = config.private_by_default;
-      });
+      if (!this.repoName) { return Promise.resolve(); }
+
+      const promises = [];
+
+      promises.push(this.$.restAPI.getProjectConfig(this.repoName)
+          .then(config => {
+            this.privateByDefault = config.private_by_default;
+          }));
+
+      promises.push(this.$.restAPI.getConfig().then(config => {
+        if (!config) { return; }
+
+        this._privateConfig = config && config.change &&
+            config.change.disable_private_changes;
+      }));
+
+      return Promise.all(promises);
     },
 
     observers: [
@@ -63,7 +77,7 @@
     ],
 
     _computeBranchClass(baseChange) {
-      return baseChange ? 'hideBranch' : '';
+      return baseChange ? 'hide' : '';
     },
 
     _allowCreate(branch, subject) {
@@ -120,5 +134,9 @@
         return false;
       }
     },
+
+    _computePrivateSectionClass(config) {
+      return config ? 'hide' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index 08c569c..aa4da68 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -158,5 +158,15 @@
         done();
       });
     });
+
+    test('_computeBranchClass', () => {
+      assert.equal(element._computeBranchClass(true), 'hide');
+      assert.equal(element._computeBranchClass(false), '');
+    });
+
+    test('_computePrivateSectionClass', () => {
+      assert.equal(element._computePrivateSectionClass(true), 'hide');
+      assert.equal(element._computePrivateSectionClass(false), '');
+    });
   });
 </script>
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 de97e62..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>
        * }>}
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 56bc17c..0625bbe 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 3a3454d..618ec65 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');
       });
     });
 
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-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 6509bb1..8b481fd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -139,11 +139,28 @@
           <gr-account-link account="[[change.owner]]"></gr-account-link>
         </span>
       </section>
-      <section class$="[[_computeShowUploaderHide(change)]]">
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
         <span class="title">Uploader</span>
         <span class="value">
           <gr-account-link
-              account="[[_computeShowUploader(change)]]"></gr-account-link>
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+              ></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+        <span class="title">Author</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+              ></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+        <span class="title">Committer</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+              ></gr-account-link>
         </span>
       </section>
       <section class="assignee">
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 8d1546b..28a08e1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -97,6 +97,18 @@
         type: Array,
         computed: '_computeParents(change)',
       },
+
+      /** @type {?} */
+      _CHANGE_ROLE: {
+        type: Object,
+        readOnly: true,
+        value: {
+          OWNER: 'owner',
+          UPLOADER: 'uploader',
+          AUTHOR: 'author',
+          COMMITTER: 'committer',
+        },
+      },
     },
 
     behaviors: [
@@ -299,24 +311,45 @@
       return !!change.work_in_progress;
     },
 
-    _computeShowUploaderHide(change) {
-      return this._computeShowUploader(change) ? '' : 'hideDisplay';
+    _computeShowRoleClass(change, role) {
+      return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
     },
 
-    _computeShowUploader(change) {
+    /**
+     * Get the user with the specified role on the change. Returns null if the
+     * user with that role is the same as the owner.
+     * @param {!Object} change
+     * @param {string} role One of the values from _CHANGE_ROLE
+     * @return {Object|null} either an accound or null.
+     */
+    _getNonOwnerRole(change, role) {
       if (!change.current_revision ||
           !change.revisions[change.current_revision]) {
         return null;
       }
 
       const rev = change.revisions[change.current_revision];
+      if (!rev) { return null; }
 
-      if (!rev || !rev.uploader ||
-        change.owner._account_id === rev.uploader._account_id) {
-        return null;
+      if (role === this._CHANGE_ROLE.UPLOADER &&
+          rev.uploader &&
+          change.owner._account_id !== rev.uploader._account_id) {
+        return rev.uploader;
       }
 
-      return rev.uploader;
+      if (role === this._CHANGE_ROLE.AUTHOR &&
+          rev.commit && rev.commit.author &&
+          change.owner.email !== rev.commit.author.email) {
+        return rev.commit.author;
+      }
+
+      if (role === this._CHANGE_ROLE.COMMITTER &&
+          rev.commit && rev.commit.committer &&
+          change.owner.email !== rev.commit.committer.email) {
+        return rev.commit.committer;
+      }
+
+      return null;
     },
 
     _computeParents(change) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index af25d91..31f19ed 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -185,115 +185,130 @@
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
-    test('_computeShowUploader test for uploader', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.deepEqual(element._computeShowUploader(change),
-          {_account_id: 1011123});
-    });
+    suite('_getNonOwnerRole', () => {
+      let change;
 
-    test('_computeShowUploader test that it does not return uploader', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
+      setup(() => {
+        change = {
+          owner: {
+            email: 'abc@def',
+            _account_id: 1019328,
+          },
+          revisions: {
+            rev1: {
+              _number: 1,
+              uploader: {
+                email: 'ghi@def',
+                _account_id: 1011123,
+              },
+              commit: {
+                author: {email: 'jkl@def'},
+                committer: {email: 'ghi@def'},
+              },
             },
           },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.isNotOk(element._computeShowUploader(change));
-    });
+          current_revision: 'rev1',
+        };
+      });
 
-    test('no current_revision makes _computeShowUploader return null', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.isNotOk(element._computeShowUploader(change));
-    });
+      suite('role=uploader', () => {
+        test('_getNonOwnerRole for uploader', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+              {email: 'ghi@def', _account_id: 1011123});
+        });
 
-    test('_computeShowUploaderHide test for string which equals true', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.equal(element._computeShowUploaderHide(change), '');
-    });
+        test('_getNonOwnerRole that it does not return uploader', () => {
+          // Set the uploader email to be the same as the owner.
+          change.revisions.rev1.uploader._account_id = 1019328;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.UPLOADER));
+        });
 
-    test('_computeShowUploaderHide test for hideDisplay', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.equal(
-          element._computeShowUploaderHide(change), 'hideDisplay');
+        test('_getNonOwnerRole null for uploader with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.UPLOADER));
+        });
+
+        test('_computeShowRoleClass show uploader', () => {
+          assert.equal(element._computeShowRoleClass(
+              change, element._CHANGE_ROLE.UPLOADER), '');
+        });
+
+        test('_computeShowRoleClass hide uploader', () => {
+          // Set the uploader email to be the same as the owner.
+          change.revisions.rev1.uploader._account_id = 1019328;
+          assert.equal(element._computeShowRoleClass(change,
+              element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
+        });
+      });
+
+      suite('role=committer', () => {
+        test('_getNonOwnerRole for committer', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+              {email: 'ghi@def'});
+        });
+
+        test('_getNonOwnerRole that it does not return committer', () => {
+          // Set the committer email to be the same as the owner.
+          change.revisions.rev1.commit.committer.email = 'abc@def';
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no commit', () => {
+          delete change.revisions.rev1.commit;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no committer', () => {
+          delete change.revisions.rev1.commit.committer;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+      });
+
+      suite('role=author', () => {
+        test('_getNonOwnerRole for author', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+              {email: 'jkl@def'});
+        });
+
+        test('_getNonOwnerRole that it does not return author', () => {
+          // Set the author email to be the same as the owner.
+          change.revisions.rev1.commit.author.email = 'abc@def';
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no commit', () => {
+          delete change.revisions.rev1.commit;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no author', () => {
+          delete change.revisions.rev1.commit.author;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+      });
     });
 
     test('_computeParents', () => {
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 3f5bd13..38f5f2f 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
@@ -531,6 +531,7 @@
             patch-num="{{_patchRange.patchNum}}"
             base-patch-num="{{_patchRange.basePatchNum}}"
             files-expanded="[[_filesExpanded]]"
+            diff-prefs-disabled="[[_diffPrefsDisabled]]"
             on-open-diff-prefs="_handleOpenDiffPrefs"
             on-open-download-dialog="_handleOpenDownloadDialog"
             on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
@@ -587,6 +588,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]]">
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 909a1ee..bb51fcc 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',
@@ -106,6 +108,14 @@
         type: Boolean,
         value: false,
       },
+      disableDiffPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsDisabled: {
+        type: Boolean,
+        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+      },
       _commentThreads: Array,
       /** @type {?} */
       _serverConfig: {
@@ -452,9 +462,9 @@
     },
 
     _handleCommentSave(e) {
-      if (!e.target.comment.__draft) { return; }
+      const draft = e.detail.comment;
+      if (!draft.__draft) { return; }
 
-      const draft = e.target.comment;
       draft.patch_set = draft.patch_set || this._patchRange.patchNum;
 
       // The use of path-based notification helpers (set, push) can’t be used
@@ -484,9 +494,9 @@
     },
 
     _handleCommentDiscard(e) {
-      if (!e.target.comment.__draft) { return; }
+      const draft = e.detail.comment;
+      if (!draft.__draft) { return; }
 
-      const draft = e.target.comment;
       if (!this._diffDrafts[draft.path]) {
         return;
       }
@@ -721,10 +731,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));
       }
     },
 
@@ -978,6 +995,8 @@
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
+      if (this._diffPrefsDisabled) { return; }
+
       e.preventDefault();
       this.$.fileList.openDiffPrefs();
     },
@@ -1664,5 +1683,9 @@
     _computeCurrentRevision(currentRevision, revisions) {
       return revisions && revisions[currentRevision];
     },
+
+    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
+      return disableDiffPrefs || !loggedIn;
+    },
   });
 })();
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 4296bd4..a88142e 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
@@ -95,6 +95,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('t to add topic', () => {
         const editStub = sandbox.stub(element.$.metadata, 'editTopic');
@@ -284,6 +298,16 @@
 
       test(', should open diff preferences', () => {
         const stub = sandbox.stub(element.$.fileList.$.diffPreferences, 'open');
+        element._loggedIn = false;
+        element.disableDiffPrefs = true;
+        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+        assert.isFalse(stub.called);
+
+        element._loggedIn = true;
+        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+        assert.isFalse(stub.called);
+
+        element.disableDiffPrefs = false;
         MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
         assert.isTrue(stub.called);
       });
@@ -575,6 +599,7 @@
       };
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {email: 'abc@def'},
         revisions: {
           rev2: {_number: 2, commit: {parents: []}},
           rev1: {_number: 1, commit: {parents: []}},
@@ -641,12 +666,12 @@
         path: '/foo/bar.txt',
         text: 'hello',
       };
-      element._handleCommentSave({target: {comment: draft}});
+      element._handleCommentSave({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
       draft.patch_set = null;
       draft.text = 'hello, there';
-      element._handleCommentSave({target: {comment: draft}});
+      element._handleCommentSave({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
       const draft2 = {
@@ -655,14 +680,14 @@
         path: '/foo/bar.txt',
         text: 'hola',
       };
-      element._handleCommentSave({target: {comment: draft2}});
+      element._handleCommentSave({detail: {comment: draft2}});
       draft2.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
       draft.patch_set = null;
-      element._handleCommentDiscard({target: {comment: draft}});
+      element._handleCommentDiscard({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-      element._handleCommentDiscard({target: {comment: draft2}});
+      element._handleCommentDiscard({detail: {comment: draft2}});
       assert.deepEqual(element._diffDrafts, {});
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index 142e706..924ddab 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -249,10 +249,10 @@
           <gr-diff-mode-selector
               id="modeSelect"
               mode="{{diffViewMode}}"
-              save-on-change="[[loggedIn]]"></gr-diff-mode-selector>
+              save-on-change="[[!diffPrefsDisabled]]"></gr-diff-mode-selector>
           <span id="diffPrefsContainer"
               class="hideOnEdit"
-              hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
+              hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
               hidden>
             <gr-button
                 link
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 665472b..b9e6288 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -62,6 +62,7 @@
       serverConfig: Object,
       shownFileCount: Number,
       diffPrefs: Object,
+      diffPrefsDisabled: Boolean,
       diffViewMode: {
         type: String,
         notify: true,
@@ -186,11 +187,10 @@
           });
     },
 
-    _computePrefsButtonHidden(prefs, loggedIn) {
-      return !loggedIn || !prefs;
+    _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
+      return diffPrefsDisabled || !prefs;
     },
 
-
     _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
       return shownFileCount <= maxFilesForBulkActions;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index e2685e1..adfeeb4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -62,21 +62,21 @@
       });
     });
 
-    test('Diff preferences hidden when no prefs or logged out', () => {
-      element.loggedIn = false;
+    test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
+      element.diffPrefsDisabled = true;
       flushAsynchronousOperations();
       assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element.loggedIn = true;
+      element.diffPrefsDisabled = false;
       flushAsynchronousOperations();
       assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element.loggedIn = false;
+      element.diffPrefsDisabled = true;
       element.diffPrefs = {font_size: '12'};
       flushAsynchronousOperations();
       assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element.loggedIn = true;
+      element.diffPrefsDisabled = false;
       flushAsynchronousOperations();
       assert.isFalse(element.$.diffPrefsContainer.hidden);
     });
@@ -265,7 +265,7 @@
 
     suite('editMode behavior', () => {
       setup(() => {
-        element.loggedIn = true;
+        element.diffPrefsDisabled = false;
         element.diffPrefs = {};
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 358e994..fa9ec7f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -400,7 +400,6 @@
                 path="[[file.__path]]"
                 prefs="[[diffPrefs]]"
                 project-name="[[change.project]]"
-                project-config="[[projectConfig]]"
                 on-line-selected="_onLineSelected"
                 no-render-on-prefs-change
                 view-mode="[[diffViewMode]]"></gr-diff-host>
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 df92b1e..51f0e5f 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
@@ -1358,7 +1358,7 @@
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
-        message: 'response',
+        message: 'a comment',
         unresolved: true,
       },
       {
@@ -1367,7 +1367,7 @@
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
         updated: '2018-02-13 22:07:43.000000000',
-        message: 'a comments',
+        message: 'response',
         unresolved: true,
       },
     ];
@@ -1704,11 +1704,43 @@
     });
 
     test('reloadCommentsForThreadWithRootId', () => {
+      // Expand the commit message diff
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      const diffs = renderAndGetNewDiffs(0);
+      flushAsynchronousOperations();
+
+      // Two comment threads should be generated by renderAndGetNewDiffs
+      const threadEls = diffs[0].getThreadEls();
+      assert.equal(threadEls.length, 2);
+      const threadElsByRootId = new Map(
+          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
+
+      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
+      assert.equal(thread1.comments.length, 1);
+      assert.equal(thread1.comments[0].message, 'a comment');
+      assert.equal(thread1.comments[0].line, 10);
+
+      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
+      assert.equal(thread2.comments.length, 2);
+      assert.isTrue(thread2.comments[0].unresolved);
+      assert.equal(thread2.comments[0].message, 'another comment');
+      assert.equal(thread2.comments[0].line, 20);
+
       const commentStub =
           sandbox.stub(element.changeComments, 'getCommentsForThread');
       const commentStubRes1 = [
         {
           patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'edited text',
+          unresolved: false,
+        },
+      ];
+      const commentStubRes2 = [
+        {
+          patch_set: 2,
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1719,6 +1751,7 @@
           patch_set: 2,
           id: '503008e2_0ab203ee',
           line: 10,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
           updated: '2018-02-14 22:07:43.000000000',
           message: 'response',
           unresolved: true,
@@ -1727,57 +1760,35 @@
           patch_set: 2,
           id: '503008e2_0ab203ef',
           line: 20,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          in_reply_to: '503008e2_0ab203ee',
           updated: '2018-02-15 22:07:43.000000000',
           message: 'a third comment in the thread',
           unresolved: true,
         },
       ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      commentStub.withArgs('cc788d2c_cb1d728c').returns(
+      commentStub.withArgs('503008e2_0ab203ee').returns(
           commentStubRes1);
       commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
           commentStubRes2);
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = renderAndGetNewDiffs(0);
-      flushAsynchronousOperations();
-
-      // Two comment threads sould be generated
-      const commentThreadEls = diffs[0].getThreadEls();
-      assert(commentThreadEls[0].comments.length, 2);
-      assert(commentThreadEls[1].comments.length, 1);
-      assert.isTrue(commentThreadEls[1].comments[0].unresolved);
-      assert.equal(commentThreadEls[1].comments[0].message, 'another comment');
-
-      // Reload comments from the first comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('cc788d2c_cb1d728c',
-          '/COMMIT_MSG');
-      assert(commentThreadEls[0].comments.length, 3);
-
 
       // Reload comments from the first comment thread, which should have a
       // an updated message and a toggled resolve state.
+      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
+          '/COMMIT_MSG');
+      assert.equal(thread1.comments.length, 1);
+      assert.isFalse(thread1.comments[0].unresolved);
+      assert.equal(thread1.comments[0].message, 'edited text');
+
+      // Reload comments from the second comment thread, which should have a new
+      // reply.
       element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
           '/COMMIT_MSG');
-      assert(commentThreadEls[1].comments.length, 1);
-      assert.isFalse(commentThreadEls[1].comments[0].unresolved);
-      assert.equal(commentThreadEls[1].comments[0].message, 'edited text');
+      assert.equal(thread2.comments.length, 3);
 
       const commentStubCount = commentStub.callCount;
       const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');
 
-      // Should not be getting threadss when the file is not expanded.
+      // Should not be getting threads when the file is not expanded.
       element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
           'other/file');
       assert.isFalse(getThreadsSpy.called);
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/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index 3650707..b1433a3 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,
@@ -353,9 +355,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;
         }
@@ -369,6 +373,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 2a62115..8ae30ad 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 f221706..584fb35 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
@@ -301,6 +301,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/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index b2fc64c..a9242be 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -14,14 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilderSideBySide) {
+(function(window, GrDiffBuilder) {
   'use strict';
 
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
-  function GrDiffBuilderBinary(diff, comments, prefs, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, null, prefs, outputEl);
+  function GrDiffBuilderBinary(diff, patchRange, commentThreadEls, prefs,
+      outputEl) {
+    GrDiffBuilder.call(this, diff, patchRange, commentThreadEls, prefs,
+        outputEl);
   }
 
   GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
@@ -43,4 +45,4 @@
   };
 
   window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-})(window, GrDiffBuilderSideBySide);
+})(window, GrDiffBuilder);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 88ff79b..c52a504 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -22,9 +22,9 @@
 
   const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(diff, comments, createThreadGroupFn, prefs,
+  function GrDiffBuilderImage(diff, patchRange, commentThreadEls, prefs,
       outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(this, diff, comments, createThreadGroupFn,
+    GrDiffBuilderSideBySide.call(this, diff, patchRange, commentThreadEls,
         prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index fafae63..da085c2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,9 +20,9 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(diff, comments, createThreadGroupFn, prefs,
-      outputEl, layers) {
-    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+  function GrDiffBuilderSideBySide(diff, patchRange, commentThreadEls,
+      prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, patchRange, commentThreadEls, prefs,
         outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 9a04b1f..0657ee4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -20,9 +20,9 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(diff, comments, createThreadGroupFn, prefs,
+  function GrDiffBuilderUnified(diff, patchRange, commentThreadEls, prefs,
       outputEl, layers) {
-    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+    GrDiffBuilder.call(this, diff, patchRange, commentThreadEls, prefs,
         outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index cc66e3b..e77eb57 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -16,9 +16,9 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
-<link rel="import" href="../gr-diff-comment-thread-group/gr-diff-comment-thread-group.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 <link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
 
@@ -29,7 +29,7 @@
     </div>
     <gr-ranged-comment-layer
         id="rangeLayer"
-        comments="[[comments]]"></gr-ranged-comment-layer>
+        comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
     <gr-syntax-layer
         id="syntaxLayer"
         diff="[[diff]]"></gr-syntax-layer>
@@ -109,32 +109,35 @@
           changeNum: String,
           patchNum: String,
           viewMode: String,
-          comments: Object,
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
-          projectName: String,
           parentIndex: Number,
+          path: String,
+          projectName: String,
           /**
            * @type {Defs.LineOfInterest|null}
            */
           lineOfInterest: Object,
 
-          /**
-           * @type {function(number, booleam, !string)}
-           */
-          createCommentFn: Function,
-
           _builder: Object,
           _groups: Array,
           _layers: Array,
           _showTabs: Boolean,
+          /** @type {!Array<!Gerrit.HoveredRange>} */
+          commentRanges: {
+            type: Array,
+          },
         },
 
         get diffElement() {
           return this.queryEffectiveChildren('#diffTable');
         },
 
+        get _commentThreadElements() {
+          return this.queryAllEffectiveChildren('.comment-thread');
+        },
+
         observers: [
           '_groupsChanged(_groups.splices)',
         ],
@@ -156,10 +159,6 @@
           }
 
           this._layers = layers;
-
-          this.async(() => {
-            this._preRenderThread();
-          });
         },
 
         render(comments, prefs) {
@@ -170,7 +169,8 @@
           // Stop the processor and syntax layer (if they're running).
           this.cancel();
 
-          this._builder = this._getDiffBuilder(this.diff, comments, prefs);
+          this._builder = this._getDiffBuilder(
+              this.diff, comments.meta.patchRange, prefs);
 
           this.$.processor.context = prefs.context;
           this.$.processor.keyLocations = this._getKeyLocations(comments,
@@ -294,7 +294,7 @@
           throw Error(`Invalid preference value: ${pref}`);
         },
 
-        _getDiffBuilder(diff, comments, prefs) {
+        _getDiffBuilder(diff, patchRange, prefs) {
           if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
             this._handlePreferenceError('tab size');
             return;
@@ -306,20 +306,22 @@
           }
 
           let builder = null;
-          const createFn = this.createCommentFn;
           if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff, comments, createFn, prefs,
-              this.diffElement, this.baseImage, this.revisionImage);
+            builder = new GrDiffBuilderImage(diff, patchRange,
+              this._commentThreadElements, prefs, this.diffElement,
+              this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
-            return new GrDiffBuilderBinary(diff, comments, prefs,
-                this.diffElement);
+            return new GrDiffBuilderBinary(diff, patchRange,
+                this._commentThreadElements, prefs, this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff, comments, createFn,
-                prefs, this.diffElement, this._layers);
+            builder = new GrDiffBuilderSideBySide(diff, patchRange,
+                this._commentThreadElements, prefs, this.diffElement,
+                this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff, comments, createFn, prefs,
-                this.diffElement, this._layers);
+            builder = new GrDiffBuilderUnified(diff, patchRange,
+                this._commentThreadElements, prefs, this.diffElement,
+                this._layers);
           }
           if (!builder) {
             throw Error('Unsupported diff view mode: ' + this.viewMode);
@@ -446,25 +448,6 @@
         },
 
         /**
-         * In pages with large diffs, creating the first comment thread can be
-         * slow because nested Polymer elements (particularly
-         * iron-autogrow-textarea) add style elements to the document head,
-         * which, in turn, triggers a reflow on the page. Create a hidden
-         * thread, attach it to the page, and remove it so the stylesheet will
-         * already exist and the user's comment will be quick to load.
-         * @see https://gerrit-review.googlesource.com/c/82213/
-         */
-        _preRenderThread() {
-          const thread = document.createElement('gr-diff-comment-thread');
-          thread.setAttribute('hidden', true);
-          thread.addDraft();
-          const parent = Polymer.dom(this.root);
-          parent.appendChild(thread);
-          Polymer.dom.flush();
-          parent.removeChild(thread);
-        },
-
-        /**
          * @return {boolean} whether any of the lines in _groups are longer
          * than SYNTAX_MAX_LINE_LENGTH.
          */
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..6ea48ac 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
@@ -20,6 +20,60 @@
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
+  /** @enum {string} */
+  Gerrit.DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+    BOTH: 'both',
+  };
+
+  /**
+   * @param {!Array<!HTMLElement>} threadEls
+   * @param {!{beforeNumber: (number|string|undefined),
+   *           afterNumber: (number|string|undefined)}}
+   *     lineInfo
+   * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT, BOTH) for
+   *     which to return the threads (default: BOTH).
+   * @return {!Array<!HTMLElement>} The thread elements matching the given
+   *     location.
+   */
+  Gerrit.filterThreadElsForLocation = function(
+      threadEls, lineInfo, side = Gerrit.DiffSide.BOTH) {
+    function matchesLeftLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          Gerrit.DiffSide.LEFT &&
+          threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
+    }
+    function matchesRightLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          Gerrit.DiffSide.RIGHT &&
+          threadEl.getAttribute('line-num') == lineInfo.afterNumber;
+    }
+    function matchesFileComment(threadEl) {
+      return (side === Gerrit.DiffSide.BOTH ||
+              threadEl.getAttribute('comment-side') == side) &&
+            // line/range comments have 1-based line set, if line is falsy it's
+            // a file comment
+            !threadEl.getAttribute('line-num');
+    }
+
+    // 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 !== Gerrit.DiffSide.RIGHT) {
+      matchers.push(matchesLeftLine);
+    }
+    if (side !== Gerrit.DiffSide.LEFT) {
+      matchers.push(matchesRightLine);
+    }
+    if (lineInfo.afterNumber === 'FILE' ||
+        lineInfo.beforeNumber === 'FILE') {
+      matchers.push(matchesFileComment);
+    }
+    return threadEls.filter(threadEl =>
+        matchers.some(matcher => matcher(threadEl)));
+  };
+
   /**
    * In JS, unicode code points above 0xFFFF occupy two elements of a string.
    * For example '𐀏'.length is 2. An occurence of such a code point is called a
@@ -42,11 +96,10 @@
    */
   const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, createThreadGroupFn, prefs, outputEl,
-      layers) {
+  function GrDiffBuilder(diff, patchRange, commentThreadEls, prefs,
+      outputEl, layers) {
     this._diff = diff;
-    this._comments = comments;
-    this._createThreadGroupFn = createThreadGroupFn;
+    this._patchRange = patchRange;
     this._prefs = prefs;
     this._outputEl = outputEl;
     this.groups = [];
@@ -67,6 +120,8 @@
         layer.addListener(this._handleLayerUpdate.bind(this));
       }
     }
+
+    this._threadEls = commentThreadEls;
   }
 
   GrDiffBuilder.GroupType = {
@@ -319,148 +374,27 @@
     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);
-      };
-    }
-    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;
-    }
-
-    return result;
-  };
-
   /**
-   * @param {Array<Object>} comments
-   * @param {string} patchForNewThreads
-   */
-  GrDiffBuilder.prototype._getThreads = function(comments, patchForNewThreads) {
-    const sortedComments = comments.slice(0).sort((a, b) => {
-      if (b.__draft && !a.__draft ) { return 0; }
-      if (a.__draft && !b.__draft ) { return 1; }
-      return util.parseDate(a.updated) - util.parseDate(b.updated);
-    });
-
-    const threads = [];
-    for (const comment of sortedComments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = threads.find(thread =>
-            thread.comments.some(c => c.id === comment.in_reply_to));
-        if (thread) {
-          thread.comments.push(comment);
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      const newThread = {
-        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,
-        rootId: comment.id || comment.__draftID,
-      };
-      if (comment.range) {
-        newThread.range = Object.assign({}, comment.range);
-      }
-      threads.push(newThread);
-    }
-    return threads;
-  };
-
-  /**
-   * Returns the patch number that new comment threads should be attached to.
-   *
-   * @param {GrDiffLine} line The line new thread will be attached to.
-   * @param {string=} opt_side Set to LEFT to force adding it to the LEFT side -
-   *     will be ignored if the left is a parent or a merge parent
-   * @return {number} Patch set to attach the new thread to
-   */
-  GrDiffBuilder.prototype._determinePatchNumForNewThreads = function(
-      patchRange, line, opt_side) {
-    if ((line.type === GrDiffLine.Type.REMOVE ||
-         opt_side === GrDiffBuilder.Side.LEFT) &&
-        patchRange.basePatchNum !== 'PARENT' &&
-        !Gerrit.PatchSetBehavior.isMergeParent(patchRange.basePatchNum)) {
-      return patchRange.basePatchNum;
-    } else {
-      return patchRange.patchNum;
-    }
-  };
-
-  /**
-   * Returns whether the comments on the given line are on a (merge) parent.
-   *
-   * @param {string} firstCommentSide
-   * @param {{basePatchNum: number, patchNum: number}} patchRange
-   * @param {GrDiffLine} line The line the comments are on.
-   * @param {string=} opt_side
-   * @return {boolean} True iff the comments on the given line are on a (merge)
-   *    parent.
-   */
-  GrDiffBuilder.prototype._determineIsOnParent = function(
-      firstCommentSide, patchRange, line, opt_side) {
-    return ((line.type === GrDiffLine.Type.REMOVE ||
-             opt_side === GrDiffBuilder.Side.LEFT) &&
-            (patchRange.basePatchNum === 'PARENT' ||
-             Gerrit.PatchSetBehavior.isMergeParent(
-                 patchRange.basePatchNum))) ||
-          firstCommentSide === 'PARENT';
-  };
-
-  /**
-   * @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, commentSide = GrDiffBuilder.Side.BOTH) {
+    const threadElsForGroup =
+        Gerrit.filterThreadElsForLocation(this._threadEls, line, commentSide);
+    if (!threadElsForGroup || threadElsForGroup.length === 0) {
       return null;
     }
 
-    const patchNum = this._determinePatchNumForNewThreads(
-        this._comments.meta.patchRange, line, opt_side);
-    const isOnParent = this._determineIsOnParent(
-        comments[0].side, this._comments.meta.patchRange, line, opt_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 = document.createElement('div');
+    threadGroupEl.className = 'thread-group';
+    for (const threadEl of threadElsForGroup) {
+      Polymer.dom(threadGroupEl).appendChild(threadEl);
+    }
+    if (commentSide) {
+      threadGroupEl.setAttribute('data-side', commentSide);
     }
     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..c277f34 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,9 +57,9 @@
 
 <script>
   suite('gr-diff-builder tests', () => {
+    let prefs;
     let element;
     let builder;
-    let createThreadGroupFn;
     let sandbox;
     const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
 
@@ -70,178 +70,74 @@
         getLoggedIn() { return Promise.resolve(false); },
         getProjectConfig() { return Promise.resolve({}); },
       });
-      const prefs = {
+      prefs = {
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
       };
-      createThreadGroupFn = sinon.spy(() => ({
-        setAttribute: sinon.spy(),
-      }));
       builder = new GrDiffBuilder(
-          {content: []}, {left: [], right: []}, createThreadGroupFn, prefs);
+          {content: []}, {left: [], right: []}, [], prefs);
     });
 
     teardown(() => { sandbox.restore(); });
 
-    test('_getThreads', () => {
-      const patchForNewThreads = 3;
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          in_reply_to: 'sallys_confession',
-        },
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        },
-      ];
+    test('filterThreadElsForLocation with no threads', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
 
-      let 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',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-            in_reply_to: 'sallys_confession',
-          }],
-          rootId: 'sallys_confession',
-          patchNum: 3,
-        },
-        {
-          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),
-          expectedThreadGroups);
-
-      // Patch num should get inherited from comment rather
-      comments.push({
-        id: 'betsys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-24 15:00:10.396000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        __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: [{
-            id: 'betsys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:10.396000000',
-            range: {
-              start_line: 1,
-              start_character: 1,
-              end_line: 1,
-              end_character: 2,
-            },
-            __commentSide: 'left',
-          }],
-          patchNum: 3,
-          rootId: 'betsys_confession',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            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),
-          expectedThreadGroups);
+      const threads = [];
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line), []);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.LEFT), []);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.RIGHT), []);
     });
 
-    test('multiple comments at same location but not threaded', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-        },
-      ];
-      assert.equal(builder._getThreads(comments, '3').length, 2);
+    test('filterThreadElsForLocation for line comments', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
+
+      const l3 = document.createElement('div');
+      l3.setAttribute('line-num', 3);
+      l3.setAttribute('comment-side', 'left');
+
+      const l5 = document.createElement('div');
+      l5.setAttribute('line-num', 5);
+      l5.setAttribute('comment-side', 'left');
+
+      const r3 = document.createElement('div');
+      r3.setAttribute('line-num', 3);
+      r3.setAttribute('comment-side', 'right');
+
+      const r5 = document.createElement('div');
+      r5.setAttribute('line-num', 5);
+      r5.setAttribute('comment-side', 'right');
+
+      const threadEls = [l3, l5, r3, r5];
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line),
+          [l3, r5]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l3]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r5]);
+    });
+
+    test('filterThreadElsForLocation for file comments', () => {
+      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+      const l = document.createElement('div');
+      l.setAttribute('comment-side', 'left');
+
+      const r = document.createElement('div');
+      r.setAttribute('comment-side', 'right');
+
+      const threadEls = [l, r];
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line),
+          [l, r]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.BOTH), [l, r]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r]);
     });
 
     test('_createElement classStr applies all classes', () => {
@@ -417,135 +313,83 @@
       }
     });
 
-    test('comments', () => {
-      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,
-          GrDiffBuilder.Side.LEFT), []);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.RIGHT), []);
-
-      comments = {
-        left: [
-          {id: 'l3', line: 3},
-          {id: 'l5', line: 5},
-        ],
-        right: [
-          {id: 'r3', line: 3},
-          {id: 'r5', line: 5},
-        ],
-      };
-      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'}]);
-    });
-
     test('comment thread group creation', () => {
-      const l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'left'};
-      const l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'left'};
-      const r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'right'};
+      const l3 = document.createElement('div');
+      l3.className = 'comment-thread';
+      l3.setAttribute('comment-side', 'left');
+      l3.setAttribute('line-num', 3);
 
-      builder._comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: '3',
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [l3, l5],
-        right: [r5],
-      };
+      const l5 = document.createElement('div');
+      l5.className = 'comment-thread';
+      l5.setAttribute('comment-side', 'left');
+      l5.setAttribute('line-num', 5);
 
-      function threadForComment(c, patchNum) {
-        return {
-          commentSide: c.__commentSide,
-          comments: [c],
-          patchNum,
-          rootId: c.id,
-          start_datetime: c.updated,
-        };
-      }
+      const r5 = document.createElement('div');
+      r5.className = 'comment-thread';
+      r5.setAttribute('comment-side', 'right');
+      r5.setAttribute('line-num', 5);
 
-      function checkThreadGroupProps(threadGroupEl, patchNum, isOnParent,
-          comments) {
-        assert.equal(createThreadGroupFn.lastCall.args[0], patchNum);
-        assert.equal(createThreadGroupFn.lastCall.args[1], isOnParent);
-        assert.deepEqual(
-            threadGroupEl.threads,
-            comments.map(c => threadForComment(c, patchNum)));
+      builder = new GrDiffBuilder(
+          {content: []}, {basePatchNum: 'PARENT', patchNum: '3'}, [l3, l5, r5],
+          prefs);
+
+      function checkThreadGroupProps(threadGroupEl,
+          expectedThreadEls) {
+        const threadEls = Polymer.dom(threadGroupEl).queryDistributedElements(
+            '.comment-thread');
+        assert.equal(threadEls.length, expectedThreadEls.length);
+        for (let i=0; i<expectedThreadEls.length; i++) {
+          assert.equal(threadEls[i], expectedThreadEls[i]);
+        }
       }
 
       let line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 5;
       line.afterNumber = 5;
       let threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.isTrue(createThreadGroupFn.calledOnce);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
+      checkThreadGroupProps(threadGroupEl, [l5, r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      assert.isTrue(createThreadGroupFn.calledTwice);
-      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
+      checkThreadGroupProps(threadGroupEl, [r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      assert.isTrue(createThreadGroupFn.calledThrice);
-      checkThreadGroupProps(threadGroupEl, '3', true, [l5]);
+      checkThreadGroupProps(threadGroupEl, [l5]);
 
-      builder._comments.meta.patchRange.basePatchNum = '1';
+      builder._patchRange.basePatchNum = '1';
 
       threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.equal(createThreadGroupFn.callCount, 4);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
+      checkThreadGroupProps(threadGroupEl, [l5, r5]);
 
       threadEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      assert.equal(createThreadGroupFn.callCount, 5);
-      checkThreadGroupProps(threadEl, '1', false, [l5]);
+      checkThreadGroupProps(threadEl, [l5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      assert.equal(createThreadGroupFn.callCount, 6);
-      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
+      checkThreadGroupProps(threadGroupEl, [r5]);
 
-      builder._comments.meta.patchRange.basePatchNum = 'PARENT';
+      builder._patchRange.basePatchNum = 'PARENT';
 
       line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 5;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.equal(createThreadGroupFn.callCount, 7);
-      checkThreadGroupProps(threadGroupEl, '3', true, [l5, r5]);
+      checkThreadGroupProps(threadGroupEl, [l5, r5]);
 
       line = new GrDiffLine(GrDiffLine.Type.ADD);
       line.beforeNumber = 3;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.equal(createThreadGroupFn.callCount, 8);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
+      checkThreadGroupProps(threadGroupEl, [l3, r5]);
     });
 
 
     test('_handlePreferenceError called with invalid preference', () => {
       sandbox.stub(element, '_handlePreferenceError');
       const prefs = {tab_size: 0};
-      element._getDiffBuilder(element.diff, element.comments, prefs);
+      element._getDiffBuilder(element.diff, undefined, prefs);
       assert.isTrue(element._handlePreferenceError.lastCall
           .calledWithExactly('tab size'));
     });
@@ -572,16 +416,18 @@
       });
 
       const lineOfInterest = {number: 789, leftSide: true};
-      assert.deepEqual(element._getKeyLocations(comments, lineOfInterest), {
-        left: {FILE: true, 123: true, 789: true},
-        right: {456: true},
-      });
+      assert.deepEqual(
+          element._getKeyLocations(comments, lineOfInterest), {
+            left: {FILE: true, 123: true, 789: true},
+            right: {456: true},
+          });
 
       delete lineOfInterest.leftSide;
-      assert.deepEqual(element._getKeyLocations(comments, lineOfInterest), {
-        left: {FILE: true, 123: true},
-        right: {456: true, 789: true},
-      });
+      assert.deepEqual(
+          element._getKeyLocations(comments, lineOfInterest), {
+            left: {FILE: true, 123: true},
+            right: {456: true, 789: true},
+          });
     });
 
     suite('_isTotal', () => {
@@ -1023,7 +869,7 @@
         processStub = sandbox.stub(element.$.processor, 'process')
             .returns(Promise.resolve());
         sandbox.stub(element, '_anyLineTooLong').returns(true);
-        comments = {left: [], right: []};
+        comments = {left: [], right: [], meta: {patchRange: undefined}};
         prefs = {
           line_length: 10,
           show_tabs: true,
@@ -1071,6 +917,7 @@
     suite('rendering', () => {
       let content;
       let outputEl;
+      let comments;
 
       setup(done => {
         const prefs = {
@@ -1098,9 +945,10 @@
         });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
+        comments = {left: [], right: [], meta: {patchRange: undefined}};
         sandbox.stub(element, '_getDiffBuilder', () => {
           const builder = new GrDiffBuilder(
-              {content}, {left: [], right: []}, null, prefs, outputEl);
+              {content}, undefined, [], prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
@@ -1112,7 +960,7 @@
           return builder;
         });
         element.diff = {content};
-        element.render({left: [], right: []}, prefs).then(done);
+        element.render(comments, prefs).then(done);
       });
 
       test('reporting', done => {
@@ -1137,7 +985,7 @@
       });
 
       test('addColumns is called', done => {
-        element.render({left: [], right: []}, {}).then(done);
+        element.render(comments, {}).then(done);
         assert.isTrue(element._builder.addColumns.called);
       });
 
@@ -1161,7 +1009,7 @@
 
       test('render-start and render are fired', done => {
         const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
-        element.render({left: [], right: []}, {}).then(() => {
+        element.render(comments, {}).then(() => {
           const firedEventTypes = dispatchEventStub.getCalls()
               .map(c => { return c.args[0].type; });
           assert.include(firedEventTypes, 'render-start');
@@ -1189,7 +1037,7 @@
           context: -1,
           syntax_highlighting: true,
         };
-        element.render({left: [], right: []}, prefs);
+        element.render(comments, prefs);
       });
 
       test('cancel', () => {
@@ -1206,6 +1054,7 @@
       let builder;
       let diff;
       let prefs;
+      let comments;
 
       setup(done => {
         element = fixture('mock-diff');
@@ -1217,8 +1066,9 @@
           show_tabs: true,
           tab_size: 4,
         };
+        comments = {left: [], right: [], meta: {patchRange: undefined}};
 
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(comments, prefs).then(() => {
           builder = element._builder;
           done();
         });
@@ -1328,7 +1178,7 @@
       test('_getNextContentOnSide unified left', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(comments, prefs).then(() => {
           builder = element._builder;
 
           const startElem = builder.getContentByLine(5, 'left',
@@ -1348,7 +1198,7 @@
       test('_getNextContentOnSide unified right', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(comments, prefs).then(() => {
           builder = element._builder;
 
           const startElem = builder.getContentByLine(5, 'right',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
deleted file mode 100644
index 58b7c32..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 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="../gr-diff-comment-thread/gr-diff-comment-thread.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-diff-comment-thread-group">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        max-width: var(--content-width, 80ch);
-        white-space: normal;
-      }
-      gr-diff-comment-thread + gr-diff-comment-thread {
-        margin-top: .2em;
-      }
-    </style>
-    <template is="dom-repeat" items="[[threads]]" as="thread">
-      <gr-diff-comment-thread
-          comments="[[thread.comments]]"
-          comment-side="[[thread.commentSide]]"
-          is-on-parent="[[isOnParent]]"
-          parent-index="[[parentIndex]]"
-          change-num="[[changeNum]]"
-          patch-num="[[thread.patchNum]]"
-          root-id="{{thread.rootId}}"
-          path="[[path]]"
-          project-name="[[projectName]]"
-          range="[[thread.range]]"
-          on-thread-discard="_handleThreadDiscard"></gr-diff-comment-thread>
-    </template>
-  </template>
-  <script src="gr-diff-comment-thread-group.js"></script>
-</dom-module>
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
deleted file mode 100644
index ae45c93..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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-diff-comment-thread-group',
-
-    properties: {
-      changeNum: String,
-      projectName: String,
-      patchForNewThreads: String,
-      range: Object,
-      isOnParent: {
-        type: Boolean,
-        value: false,
-      },
-      parentIndex: {
-        type: Number,
-        value: null,
-      },
-      threads: {
-        type: Array,
-        value() { return []; },
-      },
-    },
-
-    get threadEls() {
-      return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
-    },
-
-    /**
-     * Adds a new thread. Range is optional because a comment can be
-     * added to a line without a range selected.
-     *
-     * @param {!Object} opt_range
-     */
-    addNewThread(commentSide, opt_range) {
-      this.push('threads', {
-        comments: [],
-        commentSide,
-        patchNum: this.patchForNewThreads,
-        range: opt_range,
-      });
-    },
-
-    removeThread(rootId) {
-      for (let i = 0; i < this.threads.length; i++) {
-        if (this.threads[i].rootId === rootId) {
-          this.splice('threads', i, 1);
-          return;
-        }
-      }
-    },
-
-    /**
-     * Fetch the thread group at the given range, or the range-less thread
-     * on the line if no range is provided, lineNum, and side.
-     *
-     * @param {string} side
-     * @param {!Object=} opt_range
-     * @return {!Object|undefined}
-     */
-    getThread(side, opt_range) {
-      const threads = [].filter.call(this.threadEls,
-          thread => this._rangesEqual(thread.range, opt_range))
-          .filter(thread => thread.commentSide === side);
-      if (threads.length === 1) {
-        return threads[0];
-      }
-    },
-
-    _handleThreadDiscard(e) {
-      this.removeThread(e.detail.rootId);
-    },
-
-    /**
-     * Compare two ranges. Either argument may be falsy, but will only return
-     * true if both are falsy or if neither are falsy and have the same position
-     * values.
-     *
-     * @param {Object=} a range 1
-     * @param {Object=} b range 2
-     * @return {boolean}
-     */
-    _rangesEqual(a, b) {
-      if (!a && !b) { return true; }
-      if (!a || !b) { return false; }
-      return a.startLine === b.startLine &&
-          a.startChar === b.startChar &&
-          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
deleted file mode 100644
index 1fb8136..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ /dev/null
@@ -1,232 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 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-diff-comment-thread-group</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"/>
-<script src="../../../scripts/util.js"></script>
-
-<link rel="import" href="gr-diff-comment-thread-group.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-comment-thread-group></gr-diff-comment-thread-group>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-diff-comment-thread-group tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('getThread', () => {
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 2,
-      };
-      element.threads = [
-        {
-          rootId: 'sallys_confession',
-          commentSide: 'left',
-          comments: [
-            {
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:01:20.396000000',
-              __commentSide: 'left',
-              in_reply_to: 'sallys_confession',
-            }, {
-              id: 'new_draft',
-              message: 'i do not like either of you',
-              __commentSide: 'left',
-              __draft: true,
-              in_reply_to: 'sallys_confession',
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-        },
-        {
-          rootId: 'right_side_comment',
-          commentSide: 'right',
-          comments: [
-            {
-              id: 'right_side_comment',
-              message: 'right side comment',
-              __commentSide: 'right',
-              __draft: true,
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-        }, {
-          rootId: 'betsys_confession',
-          commentSide: 'left',
-          range,
-          comments: [
-            {
-              id: 'betsys_confession',
-              message: 'i like you more, jack',
-              updated: '2015-12-24 15:00:10.396000000',
-              range,
-              __commentSide: 'left',
-            },
-          ],
-        },
-      ];
-
-      flushAsynchronousOperations();
-      assert.deepEqual(element.getThread('right').rootId, 'right_side_comment');
-      assert.deepEqual(element.getThread('right').comments.length, 1);
-      assert.deepEqual(element.getThread('left').rootId, 'sallys_confession');
-      assert.deepEqual(element.getThread('left').comments.length, 3);
-      assert.deepEqual(element.getThread('left', range).rootId,
-          'betsys_confession');
-      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'}];
-      element.addNewThread(locationRange);
-      assert(element._threads.length, 2);
-    });
-
-    test('removeThread', () => {
-      const locationRange = 'range-1-2-3-4';
-      element._threads = [
-        {locationRange: 'range-1-2-3-4', comments: []},
-        {locationRange: 'line', comments: []},
-      ];
-      flushAsynchronousOperations();
-      element.removeThread(locationRange);
-      flushAsynchronousOperations();
-      assert(element._threads.length, 1);
-    });
-
-    test('_rangesEqual', () => {
-      const range1 =
-          {startLine: 123, startChar: 345, endLine: 234, endChar: 456};
-      const range2 =
-          {startLine: 1, startChar: 2, endLine: 3, endChar: 4};
-
-      assert.isTrue(element._rangesEqual(null, null));
-      assert.isTrue(element._rangesEqual(null, undefined));
-      assert.isTrue(element._rangesEqual(undefined, null));
-      assert.isTrue(element._rangesEqual(undefined, undefined));
-
-      assert.isFalse(element._rangesEqual(range1, null));
-      assert.isFalse(element._rangesEqual(null, range1));
-      assert.isFalse(element._rangesEqual(range1, range2));
-
-      assert.isTrue(element._rangesEqual(range1, Object.assign({}, range1)));
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index d5e6855..a2439d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -35,18 +35,48 @@
      * @event thread-changed
      */
 
+     /**
+      * gr-diff-comment-thread exposes the following attributes that allow a
+      * diff widget like gr-diff to show the thread in the right location:
+      *
+      * line-num:
+      *     1-based line number or undefined if it refers to the entire file.
+      *
+      * comment-side:
+      *     "left" or "right". These indicate which of the two diffed versions
+      *     the comment relates to. In the case of unified diff, the left
+      *     version is the one whose line number column is further to the left.
+      *
+      * range:
+      *     The range of text that the comment refers to (start_line,
+      *     start_character, end_line, end_character), serialized as JSON. If
+      *     set, range's end_line will have the same value as line-num. Line
+      *     numbers are 1-based, char numbers are 0-based. The start position
+      *     (start_line, start_character) is inclusive, and the end position
+      *     (end_line, end_character) is exclusive.
+      */
     properties: {
       changeNum: String,
       comments: {
         type: Array,
         value() { return []; },
       },
-      range: Object,
+      /**
+       * @type {?{start_line: number, start_character: number, end_line: number,
+       *          end_character: number}}
+       */
+      range: {
+        type: Object,
+        reflectToAttribute: true,
+      },
       keyEventTarget: {
         type: Object,
         value() { return document.body; },
       },
-      commentSide: String,
+      commentSide: {
+        type: String,
+        reflectToAttribute: true,
+      },
       patchNum: String,
       path: String,
       projectName: {
@@ -79,8 +109,11 @@
         type: Boolean,
         value: false,
       },
-      /** Necessary only if showFilePath is true */
-      lineNum: Number,
+      /** Necessary only if showFilePath is true or when used with gr-diff */
+      lineNum: {
+        type: Number,
+        reflectToAttribute: true,
+      },
       unresolved: {
         type: Boolean,
         notify: true,
@@ -231,7 +264,7 @@
         // Ensure drafts are at the end. There should only be one but in edge
         // cases could be more. In the unlikely event two drafts are being
         // compared, use the typical date compare.
-        if (c2.__draft && !c1.__draft ) { return 0; }
+        if (c2.__draft && !c1.__draft ) { return -1; }
         if (c1.__draft && !c2.__draft ) { return 1; }
         if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
         // If same date, fall back to sorting by id.
@@ -358,12 +391,7 @@
         d.line = opt_lineNum;
       }
       if (opt_range) {
-        d.range = {
-          start_line: opt_range.startLine,
-          start_character: opt_range.startChar,
-          end_line: opt_range.endLine,
-          end_character: opt_range.endChar,
-        };
+        d.range = opt_range;
       }
       if (this.parentIndex) {
         d.parent = this.parentIndex;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index b525a60..1881497 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -714,5 +714,38 @@
       assert.equal(element._orderedComments[1].id, '2');
       assert.equal(element._orderedComments[2].id, '3');
     });
+
+    test('reflects lineNum and commentSide to attributes', () => {
+      element.lineNum = 7;
+      element.commentSide = 'left';
+
+      assert.equal(element.getAttribute('line-num'), '7');
+      assert.equal(element.getAttribute('comment-side'), 'left');
+    });
+
+    test('reflects range to JSON serialized attribute if set', () => {
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
+
+      assert.deepEqual(
+          JSON.parse(element.getAttribute('range')),
+          {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
+    });
+
+    test('removes range attribute if range is unset', () => {
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
+      element.range = undefined;
+
+      assert.notOk(element.hasAttribute('range'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index 6ea0330..72c7285 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -91,10 +91,12 @@
         text-align: right;
         white-space: nowrap;
       }
-      a.date:link,
-      a.date:visited {
+      span.date {
         color: var(--deemphasized-text-color);
       }
+      span.date:hover {
+        text-decoration: underline;
+      }
       .actions {
         display: flex;
         justify-content: flex-end;
@@ -255,11 +257,11 @@
             on-tap="_handleCommentDelete">
           (Delete)
         </gr-button>
-        <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
+        <span class="date" on-tap="_handleAnchorTap">
           <gr-date-formatter
               has-tooltip
               date-str="[[comment.updated]]"></gr-date-formatter>
-        </a>
+        </span>
         <div class="show-hide">
           <label class="show-hide">
             <input type="checkbox" class="show-hide"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 90d465f..5165db0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -29,6 +29,8 @@
   const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
   const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
 
+  const FILE = 'FILE';
+
   Polymer({
     is: 'gr-diff-comment',
 
@@ -64,6 +66,12 @@
      * @event comment-mouse-out
      */
 
+    /**
+     * Fired when the comment's timestamp is tapped.
+     *
+     * @event comment-anchor-tap
+     */
+
     properties: {
       changeNum: String,
       /** @type {?} */
@@ -333,10 +341,6 @@
       }
     },
 
-    _computeLinkToComment(comment) {
-      return '#' + comment.line;
-    },
-
     _computeDeleteButtonClass(isAdmin, draft) {
       return isAdmin && !draft ? 'showDeleteButtons' : '';
     },
@@ -401,15 +405,16 @@
       }, STORAGE_DEBOUNCE_INTERVAL);
     },
 
-    _handleLinkTap(e) {
+    _handleAnchorTap(e) {
       e.preventDefault();
-      const hash = this._computeLinkToComment(this.comment);
-      // 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);
+      if (!this.comment.line) { return; }
+      this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
+        bubbles: true,
+        detail: {
+          number: this.comment.line || FILE,
+          side: this.side,
+        },
+      }));
     },
 
     _handleEdit(e) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index ca85892..912e615 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -99,15 +99,17 @@
           'header middle content is not visible');
     });
 
-    test('clicking on date link does not trigger nav', () => {
-      const showStub = sinon.stub(page, 'show');
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
       const dateEl = element.$$('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
-      const dest = window.location.pathname + '#5';
-      assert(showStub.lastCall.calledWithExactly(dest, null, false),
-          'Should navigate to ' + dest + ' without triggering nav');
-      showStub.restore();
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail,
+          {side: element.side, number: element.comment.line});
     });
 
     test('message is not retrieved from storage when other edits', done => {
@@ -733,17 +735,6 @@
       assert.isTrue(saveStub.calledOnce);
     });
 
-    test('clicking on date link does not trigger nav', () => {
-      const showStub = sinon.stub(page, 'show');
-      const dateEl = element.$$('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-      const dest = window.location.pathname + '#5';
-      assert(showStub.lastCall.calledWithExactly(dest, null, false),
-          'Should navigate to ' + dest + ' without triggering nav');
-      showStub.restore();
-    });
-
     test('proper event fires on resolve, comment is not saved', done => {
       const save = sandbox.stub(element, 'save');
       element.addEventListener('comment-update', e => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 9668a54..f111378 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -60,7 +60,11 @@
 
       diffElement.loggedIn = false;
       diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
-      diffElement.comments = {left: [], right: []};
+      diffElement.comments = {
+        left: [],
+        right: [],
+        meta: {patchRange: undefined},
+      };
       const setupDone = () => {
         cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
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 af8725e..85ba202 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
@@ -21,7 +21,11 @@
     is: 'gr-diff-highlight',
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        notify: true,
+      },
       loggedIn: Boolean,
       /**
        * querySelector can return null, so needs to be nullable.
@@ -35,7 +39,7 @@
     listeners: {
       'comment-mouse-out': '_handleCommentMouseOut',
       'comment-mouse-over': '_handleCommentMouseOver',
-      'create-comment': '_createComment',
+      'create-range-comment': '_createRangeComment',
     },
 
     observers: [
@@ -71,35 +75,44 @@
     },
 
     _handleCommentMouseOver(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+      const threadEl = Polymer.dom(e).localTarget;
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], true);
+        this.set(['commentRanges', index, 'hovering'], true);
       }
     },
 
     _handleCommentMouseOut(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+      const threadEl = Polymer.dom(e).localTarget;
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], false);
+        this.set(['commentRanges', index, 'hovering'], false);
       }
     },
 
-    _indexOfComment(side, comment) {
-      const idProp = comment.id ? 'id' : '__draftID';
-      for (let i = 0; i < this.comments[side].length; i++) {
-        if (comment[idProp] &&
-            this.comments[side][i][idProp] === comment[idProp]) {
-          return i;
-        }
+    _indexForThreadEl(threadEl) {
+      const side = threadEl.getAttribute('comment-side');
+      const range = JSON.parse(threadEl.getAttribute('range'));
+
+      if (!range) return undefined;
+
+      return this._indexOfCommentRange(side, range);
+    },
+
+    _indexOfCommentRange(side, range) {
+      function rangesEqual(a, b) {
+        if (!a && !b) { return true; }
+        if (!a || !b) { return false; }
+        return a.start_line === b.start_line &&
+            a.start_character === b.start_character &&
+            a.end_line === b.end_line &&
+            a.end_character === b.end_character;
       }
+
+      return this.commentRanges.findIndex(commentRange =>
+          commentRange.side === side && rangesEqual(commentRange.range, range));
     },
 
     /**
@@ -295,10 +308,10 @@
       const root = Polymer.dom(this.root);
       root.insertBefore(actionBox, root.firstElementChild);
       actionBox.range = {
-        startLine: start.line,
-        startChar: start.column,
-        endLine: end.line,
-        endChar: end.column,
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
@@ -317,7 +330,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 b10e3cc..23de407 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
@@ -205,7 +205,7 @@
 
       test('comment-mouse-over from ranged comment causes set', () => {
         sandbox.stub(element, 'set');
-        sandbox.stub(element, '_indexOfComment').returns(0);
+        sandbox.stub(element, '_indexForThreadEl').returns(0);
         element.fire('comment-mouse-over', {comment: {range: {}}});
         assert.isTrue(element.set.called);
       });
@@ -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: {},
           },
@@ -318,10 +318,10 @@
         const actionBox = element.$$('gr-selection-action-box');
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 138,
-          startChar: 5,
-          endLine: 138,
-          endChar: 12,
+          start_line: 138,
+          start_character: 5,
+          end_line: 138,
+          end_character: 12,
         });
         assert.equal(getActionSide(), 'left');
         assert.notOk(actionBox.positionBelow);
@@ -337,10 +337,10 @@
         const actionBox = element.$$('gr-selection-action-box');
 
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
         assert.equal(getActionSide(), 'right');
         assert.notOk(actionBox.positionBelow);
@@ -370,10 +370,10 @@
         element._handleSelection();
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
       });
 
@@ -383,10 +383,10 @@
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 2,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 2,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -404,10 +404,10 @@
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 8,
-          endLine: 140,
-          endChar: 23,
+          start_line: 140,
+          start_character: 8,
+          end_line: 140,
+          end_character: 23,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -418,10 +418,10 @@
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 18,
-          endLine: 140,
-          endChar: 27,
+          start_line: 140,
+          start_character: 18,
+          end_line: 140,
+          end_character: 27,
         });
       });
 
@@ -431,10 +431,10 @@
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 2,
-          endLine: 140,
-          endChar: 61,
+          start_line: 140,
+          start_character: 2,
+          end_line: 140,
+          end_character: 61,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -470,10 +470,10 @@
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 83,
-          endLine: 141,
-          endChar: 4,
+          start_line: 140,
+          start_character: 83,
+          end_line: 141,
+          end_character: 4,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -485,10 +485,10 @@
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 4,
-          endLine: 140,
-          endChar: 83,
+          start_line: 140,
+          start_character: 4,
+          end_line: 140,
+          end_character: 83,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -517,10 +517,10 @@
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 130,
-          startChar: 3,
-          endLine: 146,
-          endChar: 14,
+          start_line: 130,
+          start_character: 3,
+          end_line: 146,
+          end_character: 14,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -531,10 +531,10 @@
             content.firstChild, 1, content.querySelector('span'), 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 1,
-          endLine: 140,
-          endChar: 51,
+          start_line: 140,
+          start_character: 1,
+          end_line: 140,
+          end_character: 51,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -546,10 +546,10 @@
             content.querySelectorAll('span')[1].nextSibling, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 51,
-          endLine: 140,
-          endChar: 71,
+          start_line: 140,
+          start_character: 51,
+          end_line: 140,
+          end_character: 71,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -582,10 +582,10 @@
         emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 0,
-          endLine: 119,
-          endChar: element._getLength(startContent),
+          start_line: 119,
+          start_character: 0,
+          end_line: 119,
+          end_character: element._getLength(startContent),
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -597,10 +597,10 @@
             endContent.parentElement.previousElementSibling, 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 146,
-          startChar: 0,
-          endLine: 146,
-          endChar: 84,
+          start_line: 146,
+          start_character: 0,
+          end_line: 146,
+          end_character: 84,
         });
         assert.equal(getActionSide(), 'right');
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
index e3bf866..d335e7a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -16,9 +16,10 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 
 <dom-module id="gr-diff-host">
@@ -30,7 +31,6 @@
         patch-range="[[patchRange]]"
         path="[[path]]"
         prefs="[[prefs]]"
-        project-config="[[projectConfig]]"
         project-name="[[projectName]]"
         display-line="[[displayLine]]"
         is-image-diff="[[isImageDiff]]"
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..814c7268 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
@@ -46,12 +46,29 @@
   }
 
   /**
+   * Compare two ranges. Either argument may be falsy, but will only return
+   * true if both are falsy or if neither are falsy and have the same position
+   * values.
+   *
+   * @param {Gerrit.Range=} a range 1
+   * @param {Gerrit.Range=} b range 2
+   * @return {boolean}
+   */
+  function rangesEqual(a, b) {
+    if (!a && !b) { return true; }
+    if (!a || !b) { return false; }
+    return a.start_line === b.start_line &&
+        a.start_character === b.start_character &&
+        a.end_line === b.end_line &&
+        a.end_character === b.end_character;
+  }
+
+  /**
    * Wrapper around gr-diff.
    *
    * Webcomponent fetching diffs and related data from restAPI and passing them
    * to the presentational gr-diff for rendering.
    */
-  // TODO(oler): Move all calls to restAPI from gr-diff here.
   Polymer({
     is: 'gr-diff-host',
 
@@ -84,9 +101,6 @@
       prefs: {
         type: Object,
       },
-      projectConfig: {
-        type: Object,
-      },
       projectName: String,
       displayLine: {
         type: Boolean,
@@ -111,7 +125,10 @@
         type: Boolean,
         value: false,
       },
-      comments: Object,
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -174,10 +191,24 @@
       },
 
       _loadedWhitespaceLevel: String,
+
+      _parentIndex: {
+        type: Number,
+        computed: '_computeParentIndex(patchRange.*)',
+      },
+
+      _threadEls: {
+        type: Array,
+        value: [],
+      },
     },
 
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
     listeners: {
-      'draft-interaction': '_handleDraftInteraction',
+      'create-comment': '_handleCreateComment',
     },
 
     observers: [
@@ -299,9 +330,12 @@
       this._blame = null;
     },
 
-    /** @return {!Array<!HTMLElement>} */
+    /**
+     * The thread elements in this diff, in no particular order.
+     * @return {!Array<!HTMLElement>}
+     */
     getThreadEls() {
-      return this.$.diff.getThreadEls();
+      return this._threadEls;
     },
 
     /** @param {HTMLElement} el */
@@ -428,6 +462,70 @@
       return isImageDiff(diff);
     },
 
+
+    _commentsChanged(newComments) {
+      const allComments = [];
+      for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
+        // This is needed by the threading.
+        for (const comment of newComments[side]) {
+          comment.__commentSide = side;
+        }
+        allComments.push(...newComments[side]);
+      }
+      // Currently, the only way this is ever changed here is when the initial
+      // comments are loaded, so it's okay performance wise to clear the threads
+      // and recreate them. If this changes in future, we might want to reuse
+      // some DOM nodes here.
+      this._clearThreads();
+      const threads = this._createThreads(allComments);
+      for (const thread of threads) {
+        const threadEl = this._createThreadElement(thread);
+        this._attachThreadElement(threadEl);
+      }
+    },
+
+    /**
+     * @param {!Array<!Object>} comments
+     * @return {!Array<!Object>} Threads for the given comments.
+     */
+    _createThreads(comments) {
+      const sortedComments = comments.slice(0).sort((a, b) => {
+        if (b.__draft && !a.__draft ) { return 0; }
+        if (a.__draft && !b.__draft ) { return 1; }
+        return util.parseDate(a.updated) - util.parseDate(b.updated);
+      });
+
+      const threads = [];
+      for (const comment of sortedComments) {
+        // If the comment is in reply to another comment, find that comment's
+        // thread and append to it.
+        if (comment.in_reply_to) {
+          const thread = threads.find(thread =>
+              thread.comments.some(c => c.id === comment.in_reply_to));
+          if (thread) {
+            thread.comments.push(comment);
+            continue;
+          }
+        }
+
+        // Otherwise, this comment starts its own thread.
+        const newThread = {
+          start_datetime: comment.updated,
+          comments: [comment],
+          commentSide: comment.__commentSide,
+          patchNum: comment.patch_set,
+          rootId: comment.id || comment.__draftID,
+          lineNum: comment.line,
+          isOnParent: comment.side === 'PARENT',
+        };
+        if (comment.range) {
+          newThread.range = Object.assign({}, comment.range);
+        }
+        threads.push(newThread);
+      }
+      return threads;
+    },
+
     /**
      * @param {Object} blame
      * @return {boolean}
@@ -445,11 +543,120 @@
           this.patchRange);
     },
 
-    _handleDraftInteraction() {
+    /** @param {CustomEvent} e */
+    _handleCreateComment(e) {
+      const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+      const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
+          isOnParent);
+      threadEl.addOrEditDraft(lineNum, range);
+
       this.$.reporting.recordDraftInteraction();
     },
 
     /**
+     * Gets or creates a comment thread at a given location.
+     * May provide a range, to get/create a range comment.
+     *
+     * @param {string} patchNum
+     * @param {?number} lineNum
+     * @param {string} commentSide
+     * @param {Gerrit.Range|undefined} range
+     * @param {boolean} isOnParent
+     * @return {!Object}
+     */
+    _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
+      let threadEl = this._getThreadEl(lineNum, commentSide, range);
+      if (!threadEl) {
+        threadEl = this._createThreadElement({
+          comments: [],
+          commentSide,
+          patchNum,
+          lineNum,
+          range,
+          isOnParent,
+        });
+        this._attachThreadElement(threadEl);
+      }
+      return threadEl;
+    },
+
+    _attachThreadElement(threadEl) {
+      this._threadEls.push(threadEl);
+      Polymer.dom(this.$.diff).appendChild(threadEl);
+    },
+
+    _clearThreads() {
+      for (const threadEl of this._threadEls) {
+        const parent = Polymer.dom(threadEl).parentNode;
+        Polymer.dom(parent).removeChild(threadEl);
+      }
+      this._threadEls = [];
+    },
+
+    _createThreadElement(thread) {
+      const threadEl = document.createElement('gr-diff-comment-thread');
+      threadEl.className = 'comment-thread';
+      threadEl.comments = thread.comments;
+      threadEl.commentSide = thread.commentSide;
+      threadEl.isOnParent = !!thread.isOnParent;
+      threadEl.parentIndex = this._parentIndex;
+      threadEl.changeNum = this.changeNum;
+      threadEl.patchNum = thread.patchNum;
+      threadEl.lineNum = thread.lineNum;
+      const rootIdChangedListener = changeEvent => {
+        thread.rootId = changeEvent.detail.value;
+      };
+      threadEl.addEventListener('root-id-changed', rootIdChangedListener);
+      threadEl.path = this.path;
+      threadEl.projectName = this.projectName;
+      threadEl.range = thread.range;
+      const threadDiscardListener = e => {
+        const threadEl = /** @type {!Node} */ (e.currentTarget);
+
+        const parent = Polymer.dom(threadEl).parentNode;
+        Polymer.dom(parent).removeChild(threadEl);
+
+        const i = this._threadEls.findIndex(
+            threadEl => threadEl.rootId == e.detail.rootId);
+        this._threadEls.splice(i, 1);
+
+        threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
+        threadEl.removeEventListener('thread-discard', threadDiscardListener);
+      };
+      threadEl.addEventListener('thread-discard', threadDiscardListener);
+      return threadEl;
+    },
+
+    /**
+     * Gets a comment thread element at a given location.
+     * May provide a range, to get a range comment.
+     *
+     * @param {?number} lineNum
+     * @param {string} commentSide
+     * @param {!Gerrit.Range=} range
+     * @return {?Node}
+     */
+    _getThreadEl(lineNum, commentSide, range=undefined) {
+      let line;
+      if (commentSide === GrDiffBuilder.Side.LEFT) {
+        line = {beforeNumber: lineNum};
+      } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
+        line = {afterNumber: lineNum};
+      } else {
+        throw new Error(`Unknown side: ${commentSide}`);
+      }
+      function matchesRange(threadEl) {
+        const threadRange = /** @type {!Gerrit.Range} */(
+            JSON.parse(threadEl.getAttribute('range')));
+        return rangesEqual(threadRange, range);
+      }
+
+      const filteredThreadEls = Gerrit.filterThreadElsForLocation(
+          this._threadEls, line, commentSide).filter(matchesRange);
+      return filteredThreadEls.length ? filteredThreadEls[0] : null;
+    },
+
+    /**
      * Take a diff that was loaded with a ignore-whitespace other than
      * IGNORE_NONE, and convert delta chunks labeled as common into shared
      * chunks.
@@ -506,5 +713,15 @@
         this.reload();
       }
     },
+
+    /**
+     * @param {Object} patchRangeRecord
+     * @return {number|null}
+     */
+    _computeParentIndex(patchRangeRecord) {
+      return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
+          this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
+    },
+
   });
 })();
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..423bdc6 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
@@ -37,16 +37,50 @@
   suite('gr-diff-host tests', () => {
     let element;
     let sandbox;
+    let getLoggedIn;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      getLoggedIn = false;
+      stub('gr-rest-api-interface', {
+        async getLoggedIn() { return getLoggedIn; },
+      });
       element = fixture('basic');
+      // For reasons beyond me, fixture reuses elements, cleans out some
+      // stuff but not that list.
+      element._threadEls = [];
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
+    test('thread-discard handling', () => {
+      const threads = [
+        {comments: [{id: 4711}]},
+        {comments: [{id: 42}]},
+      ];
+      element._parentIndex = 1;
+      element.changeNum = '2';
+      element.path = 'some/path';
+      element.projectName = 'Some project';
+      const threadEls = threads.map(
+          thread => element._createThreadElement(thread));
+      assert.equal(threadEls.length, 2);
+      assert.equal(threadEls[0].rootId, 4711);
+      assert.equal(threadEls[1].rootId, 42);
+      for (const threadEl of threadEls) {
+        Polymer.dom(element).appendChild(threadEl);
+      }
+
+      threadEls[0].dispatchEvent(
+          new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
+      const attachedThreads = element.queryAllEffectiveChildren(
+          'gr-diff-comment-thread');
+      assert.equal(attachedThreads.length, 1);
+      assert.equal(attachedThreads[0].rootId, 42);
+    });
+
     test('reload() cancels before network resolves', () => {
       const cancelStub = sandbox.stub(element.$.diff, 'cancel');
 
@@ -59,12 +93,8 @@
 
     suite('not logged in', () => {
       setup(() => {
-        const getLoggedInPromise = Promise.resolve(false);
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return getLoggedInPromise; },
-        });
+        getLoggedIn = false;
         element = fixture('basic');
-        return getLoggedInPromise;
       });
 
       test('reload() loads files weblinks', () => {
@@ -181,7 +211,11 @@
               });
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {left: [], right: []};
+          element.comments = {
+            left: [],
+            right: [],
+            meta: {patchRange: element.patchRange},
+          };
         });
 
         test('renders image diffs with same file name', done => {
@@ -555,13 +589,10 @@
       });
     });
 
-    test('delegates getThreadEls()', () => {
+    test('getThreadEls() returns _threadEls', () => {
       const returnValue = [document.createElement('b')];
-      const stub = sandbox.stub(element.$.diff, 'getThreadEls')
-          .returns(returnValue);
+      element._threadEls = returnValue;
       assert.equal(element.getThreadEls(), returnValue);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
     });
 
     test('delegates addDraftAtLine(el)', () => {
@@ -617,12 +648,6 @@
       assert.equal(element.$.diff.prefs, value);
     });
 
-    test('passes in projectConfig', () => {
-      const value = {};
-      element.projectConfig = value;
-      assert.equal(element.$.diff.projectConfig, value);
-    });
-
     test('passes in changeNum', () => {
       const value = '12345';
       element.changeNum = value;
@@ -776,6 +801,190 @@
       });
     });
 
+    test('_createThreads', () => {
+      const comments = [
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000',
+          line: 1,
+          __commentSide: 'left',
+        }, {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000',
+          __commentSide: 'left',
+          line: 1,
+          in_reply_to: 'sallys_confession',
+        },
+        {
+          id: 'new_draft',
+          message: 'i do not like either of you',
+          __commentSide: 'left',
+          __draft: true,
+          updated: '2015-12-20 15:01:20.396000000',
+        },
+      ];
+
+      const actualThreads = element._createThreads(comments);
+
+      assert.equal(actualThreads.length, 2);
+
+      assert.equal(
+          actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
+      assert.equal(actualThreads[0].commentSide, 'left');
+      assert.equal(actualThreads[0].comments.length, 2);
+      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+      assert.equal(actualThreads[0].patchNum, undefined);
+      assert.equal(actualThreads[0].rootId, 'sallys_confession');
+      assert.equal(actualThreads[0].lineNum, 1);
+
+      assert.equal(
+          actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
+      assert.equal(actualThreads[1].commentSide, 'left');
+      assert.equal(actualThreads[1].comments.length, 1);
+      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+      assert.equal(actualThreads[1].patchNum, undefined);
+      assert.equal(actualThreads[1].rootId, 'new_draft');
+      assert.equal(actualThreads[1].lineNum, undefined);
+    });
+
+    test('_createThreads inherits patchNum and range', () => {
+      const comments = [{
+        id: 'betsys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:10.396000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        patch_set: 5,
+        __commentSide: 'left',
+        line: 1,
+      }];
+
+      expectedThreads = [
+        {
+          start_datetime: '2015-12-24 15:00:10.396000000',
+          commentSide: 'left',
+          comments: [{
+            id: 'betsys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:10.396000000',
+            range: {
+              start_line: 1,
+              start_character: 1,
+              end_line: 1,
+              end_character: 2,
+            },
+            patch_set: 5,
+            __commentSide: 'left',
+            line: 1,
+          }],
+          patchNum: 5,
+          rootId: 'betsys_confession',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          lineNum: 1,
+          isOnParent: false,
+        },
+      ];
+
+      assert.deepEqual(
+          element._createThreads(comments),
+          expectedThreads);
+    });
+
+    test('_createThreads does not thread unrelated comments at same location',
+        () => {
+          const comments = [
+            {
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:01:20.396000000',
+              __commentSide: 'left',
+            },
+          ];
+          assert.equal(element._createThreads(comments).length, 2);
+        });
+
+    test('_createThreads derives isOnParent using  side from first comment',
+        () => {
+          const comments = [
+            {
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              // line: 1,
+              // __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:01:20.396000000',
+              // __commentSide: 'left',
+              // line: 1,
+              in_reply_to: 'sallys_confession',
+            },
+          ];
+
+          assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+          comments[0].side = 'REVISION';
+          assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+          comments[0].side = 'PARENT';
+          assert.equal(element._createThreads(comments)[0].isOnParent, true);
+        });
+
+    test('_getOrCreateThread', () => {
+      const commentSide = 'left';
+
+      assert.isOk(element._getOrCreateThread('2', 3,
+          commentSide, undefined, false));
+
+      let threads = Polymer.dom(element.$.diff)
+          .queryDistributedElements('gr-diff-comment-thread');
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].commentSide, commentSide);
+      assert.equal(threads[0].range, undefined);
+      assert.equal(threads[0].isOnParent, false);
+      assert.equal(threads[0].patchNum, 2);
+
+
+      // Try to fetch a thread with a different range.
+      range = {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
+      };
+
+      assert.isOk(element._getOrCreateThread(
+          '3', 1, commentSide, range, true));
+
+      threads = Polymer.dom(element.$.diff)
+          .queryDistributedElements('gr-diff-comment-thread');
+
+      assert.equal(threads.length, 2);
+      assert.equal(threads[1].commentSide, commentSide);
+      assert.equal(threads[1].range, range);
+      assert.equal(threads[1].isOnParent, true);
+      assert.equal(threads[1].patchNum, 3);
+    });
+
     suite('_translateChunksToIgnore', () => {
       let content;
 
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 0866849..b3210cc 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
@@ -286,11 +286,11 @@
             <span>Diff view:</span>
             <gr-diff-mode-selector
                 id="modeSelect"
-                save-on-change="[[_loggedIn]]"
+                save-on-change="[[!_diffPrefsDisabled]]"
                 mode="{{changeViewState.diffMode}}"></gr-diff-mode-selector>
           </div>
           <span id="diffPrefsContainer"
-              hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]" hidden>
+              hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]" hidden>
             <span class="preferences desktop">
               <gr-button
                   link
@@ -332,10 +332,10 @@
         patch-range="[[_patchRange]]"
         path="[[_path]]"
         prefs="[[_prefs]]"
-        project-config="[[_projectConfig]]"
         project-name="[[_change.project]]"
         view-mode="[[_diffMode]]"
         is-blame-loaded="{{_isBlameLoaded}}"
+        on-comment-anchor-tap="_onLineSelected"
         on-line-selected="_onLineSelected">
     </gr-diff-host>
     <gr-diff-preferences
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 6f361d8..fd54af4 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
@@ -69,6 +69,14 @@
         value() { return {}; },
         observer: '_changeViewStateChanged',
       },
+      disableDiffPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsDisabled: {
+        type: Boolean,
+        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+      },
       /** @type {?} */
       _patchRange: Object,
       /** @type {?} */
@@ -161,6 +169,10 @@
         type: Object,
         computed: '_getRevisionInfo(_change)',
       },
+      _reviewedFiles: {
+        type: Object,
+        value: () => new Set(),
+      },
     },
 
     behaviors: [
@@ -207,6 +219,7 @@
         [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
         [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
         [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+        [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
 
         // Final two are actually handled by gr-diff-comment-thread.
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
@@ -450,6 +463,7 @@
     _handleCommaKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
+      if (this._diffPrefsDisabled) { return; }
 
       e.preventDefault();
       this.$.diffPreferences.open();
@@ -555,10 +569,18 @@
       return {path: fileList[idx]};
     },
 
+    _getReviewedFiles(changeNum, patchNum) {
+      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
+          .then(files => {
+            this._reviewedFiles = new Set(files);
+            return this._reviewedFiles;
+          });
+    },
+
     _getReviewedStatus(editMode, changeNum, patchNum, path) {
       if (editMode) { return Promise.resolve(false); }
-      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-          .then(files => files.includes(path));
+      return this._getReviewedFiles(changeNum, patchNum)
+          .then(files => files.has(path));
     },
 
     _paramsChanged(value) {
@@ -791,8 +813,8 @@
           (unresolvedString ? `${unresolvedString}` : '');
     },
 
-    _computePrefsButtonHidden(prefs, loggedIn) {
-      return !loggedIn || !prefs;
+    _computePrefsButtonHidden(prefs, prefsDisabled) {
+      return prefsDisabled || !prefs;
     },
 
     _handleFileChange(e) {
@@ -1012,5 +1034,19 @@
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       this.$.diffHost.expandAllContext();
     },
+
+    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
+      return disableDiffPrefs || !loggedIn;
+    },
+
+    _handleNextUnreviewedFile(e) {
+      this._setReviewed(true);
+      // Ensure that the currently viewed file always appears in unreviewedFiles
+      // so we resolve the right "next" file.
+      const unreviewedFiles = this._fileList
+          .filter(file =>
+          (file === this._path || !this._reviewedFiles.has(file)));
+      this._navToFile(this._path, unreviewedFiles, 1);
+    },
   });
 })();
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 431578b..0274330 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
@@ -67,6 +67,7 @@
     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');
+    kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
 
     let element;
     let sandbox;
@@ -135,6 +136,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
 
       const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
       const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
@@ -177,6 +179,10 @@
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
       let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
@@ -343,23 +349,39 @@
           PARENT), 'Should navigate to /c/42/1');
     });
 
-    test('Diff preferences hidden when no prefs or logged out', () => {
-      element._loggedIn = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', () => {
+        element.disableDiffPrefs = false;
+        element._loggedIn = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element._loggedIn = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element._loggedIn = false;
-      element._prefs = {font_size: '12'};
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
+        element._loggedIn = false;
+        element._prefs = {font_size: '12'};
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element._loggedIn = true;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+      });
+
+      test('when disableDiffPrefs is set', () => {
+        element._loggedIn = true;
+        element._prefs = {font_size: '12'};
+        element.disableDiffPrefs = false;
+        flushAsynchronousOperations();
+
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element.disableDiffPrefs = true;
+        flushAsynchronousOperations();
+
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+      });
     });
 
     test('prefsButton opens gr-diff-preferences', () => {
@@ -1106,5 +1128,22 @@
       assert.isTrue(setStub.calledOnce);
       assert.isTrue(setStub.calledWith(101, 'test-project'));
     });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._fileList = ['file1', 'file2', 'file3'];
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sandbox.stub(element, '_setReviewed');
+      const navStub = sandbox.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flushAsynchronousOperations();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index bddfc6d..862db10 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -20,7 +20,6 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
@@ -36,6 +35,11 @@
       :host(.no-left) .sideBySide ::content .right:not([data-value]) + td {
         display: none;
       }
+      .thread-group, ::slotted(*) .thread-group {
+        display: block;
+        max-width: var(--content-width, 80ch);
+        white-space: normal;
+      }
       .diffContainer {
         display: flex;
         font-family: var(--monospace-font-family);
@@ -276,10 +280,10 @@
         <gr-diff-highlight
             id="highlights"
             logged-in="[[loggedIn]]"
-            comments="{{comments}}">
+            comment-ranges="{{_commentRanges}}">
           <gr-diff-builder
               id="diffBuilder"
-              comments="[[comments]]"
+              comment-ranges="[[_commentRanges]]"
               project-name="[[projectName]]"
               diff="[[diff]]"
               diff-path="[[path]]"
@@ -290,9 +294,8 @@
               is-image-diff="[[isImageDiff]]"
               base-image="[[baseImage]]"
               revision-image="[[revisionImage]]"
-              parent-index="[[_parentIndex]]"
-              create-comment-fn="[[_createThreadGroupFn]]"
               line-of-interest="[[lineOfInterest]]">
+            <slot></slot>
             <table
                 id="diffTable"
                 class$="[[_diffTableClass]]"
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..f87e46f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -39,6 +39,15 @@
   const FULL_CONTEXT = -1;
   const LIMITED_CONTEXT = 10;
 
+  /** @typedef {{start_line: number, start_character: number,
+   *             end_line: number, end_character: number}} */
+  Gerrit.Range;
+
+  function isThreadEl(node) {
+    return node.nodeType === Node.ELEMENT_NODE &&
+        node.classList.contains('comment-thread');
+  }
+
   Polymer({
     is: 'gr-diff',
 
@@ -60,9 +69,9 @@
      */
 
      /**
-      * Fired when a draft is added or edited.
+      * Fired when a comment is created
       *
-      * @event draft-interaction
+      * @event create-comment
       */
 
     properties: {
@@ -78,10 +87,6 @@
         type: Object,
         observer: '_prefsObserver',
       },
-      projectConfig: {
-        type: Object,
-        observer: '_projectConfigChanged',
-      },
       projectName: String,
       displayLine: {
         type: Boolean,
@@ -100,6 +105,11 @@
         type: Object,
         value: {left: [], right: []},
       },
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      _commentRanges: {
+        type: Array,
+        value: [],
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -176,27 +186,29 @@
         observer: '_blameChanged',
       },
 
-      _parentIndex: {
-        type: Number,
-        computed: '_computeParentIndex(patchRange.*)',
-      },
+      parentIndex: Number,
 
       _newlineWarning: {
         type: String,
         computed: '_computeNewlineWarning(diff)',
       },
 
-      /**
-       * @type {function(number, boolean, !string)}
-       */
-      _createThreadGroupFn: {
-        type: Function,
-        value() {
-          return this._createCommentThreadGroup.bind(this);
-        },
-      },
-
       _diffLength: Number,
+
+      /**
+       * Observes comment nodes added or removed after the initial render.
+       * Can be used to unregister when the entire diff is (re-)rendered or upon
+       * detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _incrementalNodeObserver: Object,
+
+      /**
+       * Observes comment nodes added or removed at any point.
+       * Can be used to unregister upon detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _nodeObserver: Object,
     },
 
     behaviors: [
@@ -207,7 +219,38 @@
       'comment-discard': '_handleCommentDiscard',
       'comment-update': '_handleCommentUpdate',
       'comment-save': '_handleCommentSave',
-      'create-comment': '_handleCreateComment',
+      'create-range-comment': '_handleCreateRangeComment',
+      'render-content': '_handleRenderContent',
+    },
+
+    attached() {
+      this._updateRangesWhenNodesChange();
+    },
+
+    detached() {
+      this._unobserveIncrementalNodes();
+      this._unobserveNodes();
+    },
+
+    _updateRangesWhenNodesChange() {
+      function commentRangeFromThreadEl(threadEl) {
+        const side = threadEl.getAttribute('comment-side');
+        const range = JSON.parse(threadEl.getAttribute('range'));
+        return {side, range, hovering: false};
+      }
+
+      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        const addedCommentRanges = addedThreadEls
+            .map(commentRangeFromThreadEl)
+            .filter(({range}) => range);
+        this.push('_commentRanges', ...addedCommentRanges);
+        // In principal we should also handle removed nodes, but I have not
+        // figured out how to do that yet without also catching all the removals
+        // caused by further redistribution. Right now, comments are never
+        // removed by no longer slotting them in, so I decided to not handle
+        // this situation until it occurs.
+      });
     },
 
     /** Cancel any remaining diff builder rendering work. */
@@ -247,17 +290,6 @@
           {bubbles: true}));
     },
 
-    /** @return {!Array<!HTMLElement>} */
-    getThreadEls() {
-      let threads = [];
-      const threadGroupEls = Polymer.dom(this.root)
-          .querySelectorAll('gr-diff-comment-thread-group');
-      for (const threadGroupEl of threadGroupEls) {
-        threads = threads.concat(threadGroupEl.threadEls);
-      }
-      return threads;
-    },
-
     /** @return {string} */
     _computeContainerClass(loggedIn, viewMode, displayLine) {
       const classes = ['diffContainer'];
@@ -322,10 +354,10 @@
       this._createComment(el, lineNum);
     },
 
-    _handleCreateComment(e) {
+    _handleCreateRangeComment(e) {
       const range = e.detail.range;
       const side = e.detail.side;
-      const lineNum = range.endLine;
+      const lineNum = range.end_line;
       const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
 
       if (this._isValidElForComment(lineEl)) {
@@ -359,88 +391,49 @@
 
     /**
      * @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 patchForNewThreads = 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);
+          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+      this.dispatchEvent(new CustomEvent('create-comment', {
+        bubbles: true,
+        detail: {
+          lineNum,
+          side,
+          patchNum: patchForNewThreads,
+          isOnParent,
+          range,
+        },
+      }));
     },
 
     _getThreadGroupForLine(contentEl) {
-      return contentEl.querySelector('gr-diff-comment-thread-group');
+      return contentEl.querySelector('.thread-group');
     },
 
     /**
-     * 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}
+     * @return {!Node}
      */
-    _getOrCreateThread(contentEl, patchNum, commentSide,
-        isOnParent, opt_range) {
+    _getOrCreateThreadGroup(contentEl) {
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
-        threadGroupEl = this._createCommentThreadGroup(patchNum, isOnParent,
-            commentSide);
+        threadGroupEl = document.createElement('div');
+        threadGroupEl.className = 'thread-group';
         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;
-    },
-
-    /**
-     * @param {number} patchNum
-     * @param {boolean} isOnParent
-     * @param {!string} commentSide
-     * @return {!Object}
-     */
-    _createCommentThreadGroup(patchNum, isOnParent, commentSide) {
-      const threadGroupEl =
-          document.createElement('gr-diff-comment-thread-group');
-      threadGroupEl.changeNum = this.changeNum;
-      threadGroupEl.commentSide = commentSide;
-      threadGroupEl.patchForNewThreads = patchNum;
-      threadGroupEl.path = this.path;
-      threadGroupEl.isOnParent = isOnParent;
-      threadGroupEl.projectName = this.projectName;
-      threadGroupEl.parentIndex = this._parentIndex;
       return threadGroupEl;
     },
 
@@ -632,6 +625,7 @@
     },
 
     _renderDiffTable() {
+      this._unobserveIncrementalNodes();
       if (!this.prefs) {
         this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
         return;
@@ -648,6 +642,39 @@
       this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
     },
 
+    _handleRenderContent() {
+      this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        // In principal we should also handle removed nodes, but I have not
+        // figured out how to do that yet without also catching all the removals
+        // caused by further redistribution. Right now, comments are never
+        // removed by no longer slotting them in, so I decided to not handle
+        // this situation until it occurs.
+        for (const threadEl of addedThreadEls) {
+          const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+          const commentSide = threadEl.getAttribute('comment-side');
+          const lineEl = this.$.diffBuilder.getLineElByNumber(
+              lineNumString, commentSide);
+          const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+          const contentEl = contentText.parentElement;
+          const threadGroupEl = this._getOrCreateThreadGroup(contentEl);
+          Polymer.dom(threadGroupEl).appendChild(threadEl);
+        }
+      });
+    },
+
+    _unobserveIncrementalNodes() {
+      if (this._incrementalNodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
+      }
+    },
+
+    _unobserveNodes() {
+      if (this._nodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._nodeObserver);
+      }
+    },
+
     /**
      * Get the preferences object including the safety bypass context (if any).
      */
@@ -659,16 +686,10 @@
     },
 
     clearDiffContent() {
+      this._unobserveIncrementalNodes();
       this.$.diffTable.innerHTML = null;
     },
 
-    _projectConfigChanged(projectConfig) {
-      const threadEls = this.getThreadEls();
-      for (let i = 0; i < threadEls.length; i++) {
-        threadEls[i].projectConfig = projectConfig;
-      }
-    },
-
     /** @return {!Array} */
     _computeDiffHeaderItems(diffInfoRecord) {
       const diffInfo = diffInfoRecord.base;
@@ -710,16 +731,6 @@
       return errorMessage ? 'showError' : '';
     },
 
-    /**
-     * @return {number|null}
-     */
-    _computeParentIndex(patchRangeRecord) {
-      if (!this.isMergeParent(patchRangeRecord.base.basePatchNum)) {
-        return null;
-      }
-      return this.getParentIndex(patchRangeRecord.base.basePatchNum);
-    },
-
     expandAllContext() {
       this._handleFullBypass();
     },
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..4befd2f 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
@@ -295,15 +295,6 @@
 
       test('thread groups', () => {
         const contentEl = document.createElement('div');
-        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 +302,20 @@
 
         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);
+        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);
+        assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
       });
 
       suite('image diffs', () => {
@@ -359,7 +334,11 @@
           };
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {left: [], right: []};
+          element.comments = {
+            left: [],
+            right: [],
+            meta: {patchRange: undefined},
+          };
           element.isImageDiff = true;
           element.prefs = {
             auto_hide_diff_table_header: true,
@@ -688,6 +667,7 @@
           element.comments = {
             left: [],
             right: [],
+            meta: {patchRange: undefined},
           };
           element.prefs = {
             context: 10,
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index db14fc8..fa488f0 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -17,31 +17,34 @@
 (function() {
   'use strict';
 
-  const HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
-  const SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+  const HOVER_PATH_PATTERN = /^commentRanges\.\#(\d+)\.hovering$/;
 
   const RANGE_HIGHLIGHT = 'range';
   const HOVER_HIGHLIGHT = 'rangeHighlight';
 
   const NORMALIZE_RANGE_EVENT = 'normalize-range';
 
+  /** @typedef {{side: string, range: Gerrit.Range, hovering: boolean}} */
+  Gerrit.HoveredRange;
+
   Polymer({
     is: 'gr-ranged-comment-layer',
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: Array,
       _listeners: {
         type: Array,
         value() { return []; },
       },
-      _commentMap: {
+      _rangesMap: {
         type: Object,
-        value() { return {left: [], right: []}; },
+        value() { return {left: {}, right: {}}; },
       },
     },
 
     observers: [
-      '_handleCommentChange(comments.*)',
+      '_handleCommentRangesChange(commentRanges.*)',
     ],
 
     /**
@@ -93,97 +96,78 @@
     },
 
     /**
-     * Handle change in the comments by updating the comment maps and by
+     * Handle change in the ranges by updating the ranges maps and by
      * emitting appropriate update notifications.
      * @param {Object} record The change record.
      */
-    _handleCommentChange(record) {
-      if (!record.path) { return; }
+    _handleCommentRangesChange(record) {
+      if (!record) return;
 
       // If the entire set of comments was changed.
-      if (record.path === 'comments') {
-        this._commentMap.left = this._computeCommentMap(this.comments.left);
-        this._commentMap.right = this._computeCommentMap(this.comments.right);
-        return;
+      if (record.path === 'commentRanges') {
+        this._rangesMap = {left: {}, right: {}};
+        for (const {side, range, hovering} of record.value) {
+          this._updateRangesMap(
+              side, range, hovering, (forLine, start, end, hovering) => {
+                forLine.push({start, end, hovering});
+              });
+        }
       }
 
       // If the change only changed the `hovering` property of a comment.
-      let match = record.path.match(HOVER_PATH_PATTERN);
-      let side;
-
+      const match = record.path.match(HOVER_PATH_PATTERN);
       if (match) {
-        side = match[1];
-        const index = match[2];
-        const comment = this.comments[side][index];
-        if (comment && comment.range) {
-          this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-          this._notifyUpdateRange(
-              comment.range.start_line, comment.range.end_line, side);
-        }
-        return;
+        const commentRangesIndex = match[1];
+        const {side, range, hovering} = this.commentRanges[commentRangesIndex];
+        this._updateRangesMap(
+            side, range, hovering, (forLine, start, end, hovering) => {
+              const index = forLine.findIndex(lineRange =>
+                  lineRange.start === start && lineRange.end === end);
+              forLine[index].hovering = hovering;
+            });
       }
 
       // If comments were spliced in or out.
-      match = record.path.match(SPLICE_PATH_PATTERN);
-      if (match) {
-        side = match[1];
-        this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-        this._handleCommentSplice(record.value, side);
+      if (record.path === 'commentRanges.splices') {
+        for (const indexSplice of record.value.indexSplices) {
+          const removed = indexSplice.removed;
+          for (const {side, range, hovering} of removed) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end) => {
+                  const index = forLine.findIndex(lineRange =>
+                      lineRange.start === start && lineRange.end === end);
+                  forLine.splice(index, 1);
+                });
+          }
+          const added = indexSplice.object.slice(
+              indexSplice.index, indexSplice.index + indexSplice.addedCount);
+          for (const {side, range, hovering} of added) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end, hovering) => {
+                  forLine.push({start, end, hovering});
+                });
+          }
+        }
       }
     },
 
-    /**
-     * Take a list of comments and return a sparse list mapping line numbers to
-     * partial ranges. Uses an end-character-index of -1 to indicate the end of
-     * the line.
-     * @param {?} commentList The list of comments.
-     *    Getting this param to match closure requirements caused problems.
-     * @return {!Object} The sparse list.
-     */
-    _computeCommentMap(commentList) {
-      const result = {};
-      for (const comment of commentList) {
-        if (!comment.range) { continue; }
-        const range = comment.range;
-        for (let line = range.start_line; line <= range.end_line; line++) {
-          if (!result[line]) { result[line] = []; }
-          result[line].push({
-            comment,
-            start: line === range.start_line ? range.start_character : 0,
-            end: line === range.end_line ? range.end_character : -1,
-          });
-        }
+    _updateRangesMap(side, range, hovering, operation) {
+      const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+      for (let line = range.start_line; line <= range.end_line; line++) {
+        const forLine = forSide[line] || (forSide[line] = []);
+        const start = line === range.start_line ? range.start_character : 0;
+        const end = line === range.end_line ? range.end_character : -1;
+        operation(forLine, start, end, hovering);
       }
-      return result;
-    },
-
-    /**
-     * Translate a splice record into range update notifications.
-     */
-    _handleCommentSplice(record, side) {
-      if (!record || !record.indexSplices) { return; }
-
-      for (const splice of record.indexSplices) {
-        const ranges = splice.removed.length ?
-            splice.removed.map(c => { return c.range; }) :
-            [splice.object[splice.index].range];
-        for (const range of ranges) {
-          if (!range) { continue; }
-          this._notifyUpdateRange(range.start_line, range.end_line, side);
-        }
-      }
+      this._notifyUpdateRange(range.start_line, range.end_line, side);
     },
 
     _getRangesForLine(line, side) {
       const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      const ranges = this.get(['_commentMap', side, lineNum]) || [];
+      const ranges = this.get(['_rangesMap', side, lineNum]) || [];
       return ranges
           .map(range => {
-            range = {
-              start: range.start,
-              end: range.end === -1 ? line.text.length : range.end,
-              hovering: !!range.comment.__hovering,
-            };
+            range.end = range.end === -1 ? line.text.length : range.end;
 
             // Normalize invalid ranges where the start is after the end but the
             // start still makes sense. Set the end to the end of the line.
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index c541e26..c198ace 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -40,62 +40,48 @@
     let sandbox;
 
     setup(() => {
-      const initialComments = {
-        left: [
-          {
-            id: '12345',
-            line: 39,
-            message: 'range comment',
-            range: {
-              end_character: 9,
-              end_line: 39,
-              start_character: 6,
-              start_line: 36,
-            },
-          }, {
-            id: '23456',
-            line: 100,
-            message: 'non range comment',
+      const initialCommentRanges = [
+        {
+          side: 'left',
+          range: {
+            end_character: 9,
+            end_line: 39,
+            start_character: 6,
+            start_line: 36,
           },
-        ],
-        right: [
-          {
-            id: '34567',
-            line: 10,
-            message: 'range comment',
-            range: {
-              end_character: 22,
-              end_line: 12,
-              start_character: 10,
-              start_line: 10,
-            },
-          }, {
-            id: '45678',
-            line: 100,
-            message: 'single line range comment',
-            range: {
-              end_character: 15,
-              end_line: 100,
-              start_character: 5,
-              start_line: 100,
-            },
-          }, {
-            id: '8675309',
-            line: 55,
-            message: 'nonsense range',
-            range: {
-              end_character: 2,
-              end_line: 55,
-              start_character: 32,
-              start_line: 55,
-            },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 22,
+            end_line: 12,
+            start_character: 10,
+            start_line: 10,
           },
-        ],
-      };
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 15,
+            end_line: 100,
+            start_character: 5,
+            start_line: 100,
+          },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 2,
+            end_line: 55,
+            start_character: 32,
+            start_line: 55,
+          },
+        },
+      ];
 
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.comments = initialComments;
+      element.commentRanges = initialCommentRanges;
     });
 
     teardown(() => {
@@ -149,7 +135,7 @@
       test('type=Remove has-comment hovering', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
-        element.set(['comments', 'left', 0, '__hovering'], true);
+        element.set(['commentRanges', 0, 'hovering'], true);
 
         const expectedStart = 6;
         const expectedLength = line.text.length - expectedStart;
@@ -210,29 +196,18 @@
       });
     });
 
-    test('_handleCommentChange overwrite', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange overwrite', () => {
+      element.set('commentRanges', []);
 
-      element.set('comments', {left: [], right: []});
-
-      assert.isTrue(handlerSpy.called);
-      assert.equal(mapSpy.callCount, 2);
-
-      assert.equal(Object.keys(element._commentMap.left).length, 0);
-      assert.equal(Object.keys(element._commentMap.right).length, 0);
+      assert.equal(Object.keys(element._rangesMap.left).length, 0);
+      assert.equal(Object.keys(element._rangesMap.right).length, 0);
     });
 
-    test('_handleCommentChange hovering', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange hovering', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.set(['comments', 'right', 0, '__hovering'], true);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.set(['commentRanges', 1, 'hovering'], true);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -241,16 +216,11 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice out', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice out', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.right', 0, 1);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.splice('commentRanges', 1, 1);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -259,16 +229,12 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice in', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice in', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.left', element.comments.left.length, 0, {
-        id: '56123',
-        line: 250,
-        message: 'new range comment',
+      element.splice('commentRanges', 1, 0, {
+        side: 'left',
         range: {
           end_character: 15,
           end_line: 275,
@@ -277,9 +243,6 @@
         },
       });
 
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
-
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 250);
@@ -291,48 +254,48 @@
       // There is only one ranged comment on the left, but it spans ll.36-39.
       const leftKeys = [];
       for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-      assert.deepEqual(Object.keys(element._commentMap.left).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
           leftKeys.sort());
 
-      assert.equal(element._commentMap.left[36].length, 1);
-      assert.equal(element._commentMap.left[36][0].start, 6);
-      assert.equal(element._commentMap.left[36][0].end, -1);
+      assert.equal(element._rangesMap.left[36].length, 1);
+      assert.equal(element._rangesMap.left[36][0].start, 6);
+      assert.equal(element._rangesMap.left[36][0].end, -1);
 
-      assert.equal(element._commentMap.left[37].length, 1);
-      assert.equal(element._commentMap.left[37][0].start, 0);
-      assert.equal(element._commentMap.left[37][0].end, -1);
+      assert.equal(element._rangesMap.left[37].length, 1);
+      assert.equal(element._rangesMap.left[37][0].start, 0);
+      assert.equal(element._rangesMap.left[37][0].end, -1);
 
-      assert.equal(element._commentMap.left[38].length, 1);
-      assert.equal(element._commentMap.left[38][0].start, 0);
-      assert.equal(element._commentMap.left[38][0].end, -1);
+      assert.equal(element._rangesMap.left[38].length, 1);
+      assert.equal(element._rangesMap.left[38][0].start, 0);
+      assert.equal(element._rangesMap.left[38][0].end, -1);
 
-      assert.equal(element._commentMap.left[39].length, 1);
-      assert.equal(element._commentMap.left[39][0].start, 0);
-      assert.equal(element._commentMap.left[39][0].end, 9);
+      assert.equal(element._rangesMap.left[39].length, 1);
+      assert.equal(element._rangesMap.left[39][0].start, 0);
+      assert.equal(element._rangesMap.left[39][0].end, 9);
 
       // The right has two ranged comments, one spanning ll.10-12 and the other
       // on line 100.
       const rightKeys = [];
       for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
       rightKeys.push('55', '100');
-      assert.deepEqual(Object.keys(element._commentMap.right).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
           rightKeys.sort());
 
-      assert.equal(element._commentMap.right[10].length, 1);
-      assert.equal(element._commentMap.right[10][0].start, 10);
-      assert.equal(element._commentMap.right[10][0].end, -1);
+      assert.equal(element._rangesMap.right[10].length, 1);
+      assert.equal(element._rangesMap.right[10][0].start, 10);
+      assert.equal(element._rangesMap.right[10][0].end, -1);
 
-      assert.equal(element._commentMap.right[11].length, 1);
-      assert.equal(element._commentMap.right[11][0].start, 0);
-      assert.equal(element._commentMap.right[11][0].end, -1);
+      assert.equal(element._rangesMap.right[11].length, 1);
+      assert.equal(element._rangesMap.right[11][0].start, 0);
+      assert.equal(element._rangesMap.right[11][0].end, -1);
 
-      assert.equal(element._commentMap.right[12].length, 1);
-      assert.equal(element._commentMap.right[12][0].start, 0);
-      assert.equal(element._commentMap.right[12][0].end, 22);
+      assert.equal(element._rangesMap.right[12].length, 1);
+      assert.equal(element._rangesMap.right[12][0].start, 0);
+      assert.equal(element._rangesMap.right[12][0].end, 22);
 
-      assert.equal(element._commentMap.right[100].length, 1);
-      assert.equal(element._commentMap.right[100][0].start, 5);
-      assert.equal(element._commentMap.right[100][0].end, 15);
+      assert.equal(element._rangesMap.right[100].length, 1);
+      assert.equal(element._rangesMap.right[100][0].start, 5);
+      assert.equal(element._rangesMap.right[100][0].end, 15);
     });
 
     test('_getRangesForLine normalizes invalid ranges', () => {
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..fa5c810 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: {
@@ -34,10 +34,10 @@
       range: {
         type: Object,
         value: {
-          startLine: NaN,
-          startChar: NaN,
-          endLine: NaN,
-          endChar: NaN,
+          start_line: NaN,
+          start_character: NaN,
+          end_line: NaN,
+          end_character: NaN,
         },
       },
       positionBelow: Boolean,
@@ -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..dece366 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
@@ -89,16 +89,16 @@
     test('event fired contains playload', () => {
       const side = 'left';
       const range = {
-        startLine: 1,
-        startChar: 11,
-        endLine: 2,
-        endChar: 42,
+        start_line: 1,
+        start_character: 11,
+        end_line: 2,
+        end_character: 42,
       };
       element.side = 'left';
       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 0cf517d..321dc58 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -275,6 +275,8 @@
       this.bindShortcut(
           this.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
       this.bindShortcut(
+          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      this.bindShortcut(
           this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
       this.bindShortcut(
           this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
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 10d3e0d..20a4a1e 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -103,7 +103,6 @@
     'core/gr-smart-search/gr-smart-search_test.html',
     'diff/gr-comment-api/gr-comment-api_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
     'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 08d5045..ccde467 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -68,7 +68,6 @@
     # Enforce JDK 8 compatibility on Java 9, see
     # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
     "-Djava.locale.providers=COMPAT,CLDR,SPI",
-    "--add-modules java.activation",
     "--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED",
 ]
 
@@ -82,7 +81,7 @@
     jvm_flags = kwargs.get("jvm_flags", [])
     jvm_flags = jvm_flags + select({
         "//:java9": POST_JDK8_OPTS,
-        "//:java10": POST_JDK8_OPTS,
+        "//:java_next": POST_JDK8_OPTS,
         "//conditions:default": [],
     })
     native.java_test(
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index e66a938..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-rc3</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>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 623964c..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-rc3</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 212e739..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-rc3</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-plugin-gwtui_pom.xml b/tools/maven/gerrit-plugin-gwtui_pom.xml
index 1fe482c..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-rc3</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>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 4a84174..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-rc3</version>
+  <version>3.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index 04b03a7..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-rc3"
+GERRIT_VERSION = "3.0-SNAPSHOT"