Merge "Fix diff-builder leaks"
diff --git a/.gitignore b/.gitignore
index 3a5d28b..bcafbb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,8 @@
 /node_modules/
 /package-lock.json
 /plugins/*
+!/plugins/package.json
+!/plugins/yarn.lock
 !/plugins/BUILD
 !/plugins/codemirror-editor
 !/plugins/commit-message-length-validator
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 94c4552..ceef953 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1703,7 +1703,8 @@
 
 [[container.slave]]container.slave::
 +
-Backward compatibility for 'container.slave' config setting.
+Backward compatibility for link:#container.replica[`container.replica`]
+config setting.
 
 [[container.startupTimeout]]container.startupTimeout::
 +
@@ -2242,6 +2243,25 @@
 +
 By default false.
 
+[[gerrit.xframeOption]]gerrit.xframeOption::
++
+Add link:https://tools.ietf.org/html/rfc7034[`X-Frame-Options`] header to all HTTP
+responses. The `X-Frame-Options` HTTP response header can be used to indicate
+whether or not a browser should be allowed to render a page in a
+`<frame>`, `<iframe>`, `<embed>` or `<object>`.
++
+Available values:
++
+1. ALLOW - The page can be displayed in a frame.
+2. SAMEORIGIN - The page can only be displayed in a frame on the same origin as the page itself.
++
+If link:#gerrit.canLoadInIFrame is set to false this option is ignored and the
+`X-Frame-Options` header is always set to `DENY`.
+Setting this option to `ALLOW` will cause the `X-Frame-Options` header to be omitted
+the the page can be displayed in a frame.
++
+By default SAMEORIGIN.
+
 [[gerrit.cdnPath]]gerrit.cdnPath::
 +
 Path prefix for PolyGerrit's static resources if using a CDN.
@@ -2976,7 +2996,7 @@
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
-online schema upgrades.
+online schema upgrades, and also for offline reindexing.
 +
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index c298ba1..a01df50 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -316,27 +316,35 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[content_merge]]
-- 'mergeContent': Defines whether Gerrit will try to
+[[content_merge]]submit.mergeContent::
++
+Defines whether Gerrit will try to
 do a content merge when a path conflict occurs. Valid values are
 'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
 be modified by any project owner through the project console, `Browse`
 > `Repositories` > my/project > `Allow content merges`.
 
-- 'action': Defines the link:#submit-type[submit type].  Valid
+[[submit.action]]submit.action::
++
+Defines the link:#submit-type[submit type].  Valid
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
 'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
 
-- 'matchAuthorToCommitterDate': Defines whether to the author date will be changed to match the
-submitter date upon submit, so that git log shows when the change was submitted instead of when the
-author last committed. Valid values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'.
-This option only takes effect in submit strategies which already modify the commit, i.e.
-Cherry Pick, Rebase Always, and (perhaps) Rebase If Necessary.
+[[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
++
+Defines whether the author date will be changed to match the submitter date upon submit, so that
+git log shows when the change was submitted instead of when the author last committed. Valid
+values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'. This option only takes effect
+in submit strategies which already modify the commit, i.e. Cherry Pick, Rebase Always, and
+(when rebase is necessary) Rebase If Necessary.
 
-- 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
-Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
-commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
-the initial commit on a branch.
+[[submit.rejectEmptyCommit]]submit.rejectEmptyCommit::
++
+Defines whether empty commits should be rejected when a change is merged. When using
+link:#submit.action[submit action] Cherry Pick, Rebase If Necessary or Rebase Always changes may
+become empty upon submit, since the rebase|cherry-pick can lead to an empty commit. If this option
+is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
+initial commit on a branch.
 
 [[submit-type]]
 ==== Submit Type
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 5a54c5f..9bc7f0b 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -116,7 +116,7 @@
 link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
 tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`,role=external,window=_blank]
-tool (version 3.0.0). Unused dependencies are found and removed using the
+tool (version 3.2.1). Unused dependencies are found and removed using the
 link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`,role=external,window=_blank]
 build tool, a sibling of `buildifier`.
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index 2ca7f22..cecaedc 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -213,10 +213,10 @@
 [[maintainer-election]]
 Maintainers can nominate new maintainers by posting a nomination on the
 non-public maintainers mailing list. Nominations should stay open for
-at least 14 calendar days so that all maintainers have a chance to
+at least 10 calendar days so that all maintainers have a chance to
 vote. To be approved as maintainer a minimum of 5 positive votes and no
 negative votes is required. This means if 5 positive votes without
-negative votes have been reached and 14 calendar days have passed, any
+negative votes have been reached and 10 calendar days have passed, any
 maintainer can close the vote and welcome the new maintainer. Extending
 the voting period during holiday season or if there are not enough
 votes is possible, but the voting period should not exceed 1 month. If
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 0505dd2..89758a0 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -110,6 +110,13 @@
 normally run `gerrit.war daemon` with an `-Xmx` flag, pass that to the migration
 tool as well.
 
+[NOTE]
+Note that by appending `--reindex false` to the above command, you can skip the
+lengthy, implicit reindexing step of the migration. This is useful if you plan
+to perform further Gerrit upgrades while the server is offline and have to
+reindex later anyway (E.g.: a follow-up upgrade to Gerrit 3.2 or newer, which
+requires to reindex changes anyway).
+
 *Advantages*
 
 * Much faster than online; can use all available CPUs, since no live traffic
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4006d1d..066f6d8 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -4249,7 +4249,6 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/submit HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
 ----
 
 As response a link:#submit-info[SubmitInfo] entity is returned that
@@ -4471,7 +4470,7 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/test.submit_type HTTP/1.0
-  Content-Type: text/plain; charset-UTF-8
+  Content-Type: text/plain; charset=UTF-8
 
   submit_type(cherry_pick).
 ----
@@ -4502,7 +4501,7 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/test.submit_rule?filters=SKIP HTTP/1.0
-  Content-Type: text/plain; charset-UTF-8
+  Content-Type: text/plain; charset=UTF-8
 
   submit_rule(submit(R)) :-
     R = label('Any-Label-Name', reject(_)).
@@ -6191,7 +6190,7 @@
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `NONE`.
+If not set, the default is `ALL`.
 |`notify_details`   |optional|
 Additional information about whom to notify about the update as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
diff --git a/README.md b/README.md
index 084f2b0..30b7d0b 100644
--- a/README.md
+++ b/README.md
@@ -77,13 +77,13 @@
 
 Docker images of Gerrit are available on [DockerHub](https://hub.docker.com/u/gerritforge/)
 
-To run a CentOS 7 based Gerrit image:
+To run a CentOS 8 based Gerrit image:
 
-        docker run -p 8080:8080 gerritforge/gerrit-centos7[:version]
+        docker run -p 8080:8080 gerritcodereview/gerrit[:version]-centos8
 
-To run a Ubuntu 15.04 based Gerrit image:
+To run a Ubuntu 20.04 based Gerrit image:
 
-        docker run -p 8080:8080 gerritforge/gerrit-ubuntu15.04[:version]
+        docker run -p 8080:8080 gerritcodereview/gerrit[:version]-ubuntu20
 
 _NOTE: release is optional. Last released package of the version is installed if the release
 number is omitted._
diff --git a/WORKSPACE b/WORKSPACE
index 91eef76..47c3e9c 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -10,6 +10,8 @@
 #    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
 # 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
 #    folder can be used for testing, but must not be included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
+#    The packages here are expected to be used in plugins.
 # Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
 #    two managed directories from the same package.json. At the same time we want to avoid accidental
 #    usages of code from devDependencies in polygerrit bundle.
@@ -20,6 +22,7 @@
         "@ui_npm": ["polygerrit-ui/app/node_modules"],
         "@ui_dev_npm": ["polygerrit-ui/node_modules"],
         "@tools_npm": ["tools/node_tools/node_modules"],
+        "@plugins_npm": ["plugins/node_modules"],
     },
 )
 
@@ -647,18 +650,18 @@
     sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
 )
 
-AUTO_VALUE_VERSION = "1.7.2"
+AUTO_VALUE_VERSION = "1.7.3"
 
 maven_jar(
     name = "auto-value",
     artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "895dbc8f1764f162c1dae34cc29e300220d6d4ba",
+    sha1 = "cbd30873f839545c7c9264bed61d500bf85bd33e",
 )
 
 maven_jar(
     name = "auto-value-annotations",
     artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "7eec707327ec1663b9387c8671efb6808750e039",
+    sha1 = "59ce5ee6aea918f674229f1147da95fdf7f31ce6",
 )
 
 declare_nongoogle_deps()
@@ -1208,6 +1211,13 @@
     yarn_lock = "//:tools/node_tools/yarn.lock",
 )
 
+yarn_install(
+    name = "plugins_npm",
+    args = ["--prod"],
+    package_json = "//:plugins/package.json",
+    yarn_lock = "//:plugins/yarn.lock",
+)
+
 # Install all Bazel dependencies needed for npm packages that supply Bazel rules
 load("@npm//:install_bazel_dependencies.bzl", "install_bazel_dependencies")
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index f64d7a2..8c1eebd 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountState;
@@ -60,8 +63,8 @@
 
   private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
-        (account, updateBuilder) ->
-            fillBuilder(updateBuilder, accountCreation, account.account().id());
+        (accountState, updateBuilder) ->
+            fillBuilder(updateBuilder, accountCreation, accountState.account().id());
     AccountState createdAccount = createAccount(accountUpdater);
     return createdAccount.account().id();
   }
@@ -82,6 +85,11 @@
     accountCreation.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
     accountCreation.status().ifPresent(builder::setStatus);
     accountCreation.active().ifPresent(builder::setActive);
+    accountCreation
+        .secondaryEmails()
+        .forEach(
+            secondaryEmail ->
+                builder.addExternalId(ExternalId.createEmail(accountId, secondaryEmail)));
   }
 
   private static InternalAccountUpdate.Builder setPreferredEmail(
@@ -136,6 +144,7 @@
           .fullname(Optional.ofNullable(account.fullName()))
           .username(accountState.userName())
           .active(accountState.account().isActive())
+          .emails(ExternalId.getEmails(accountState.externalIds()).collect(toImmutableSet()))
           .build();
     }
 
@@ -147,7 +156,7 @@
     private void updateAccount(TestAccountUpdate accountUpdate)
         throws IOException, ConfigInvalidException {
       AccountsUpdate.AccountUpdater accountUpdater =
-          (account, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountId);
+          (accountState, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountState);
       Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
     }
@@ -160,13 +169,58 @@
     private void fillBuilder(
         InternalAccountUpdate.Builder builder,
         TestAccountUpdate accountUpdate,
-        Account.Id accountId) {
+        AccountState accountState) {
       accountUpdate.fullname().ifPresent(builder::setFullName);
       accountUpdate.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
       String httpPassword = accountUpdate.httpPassword().orElse(null);
       accountUpdate.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
       accountUpdate.status().ifPresent(builder::setStatus);
       accountUpdate.active().ifPresent(builder::setActive);
+
+      ImmutableSet<String> secondaryEmails = getSecondaryEmails(accountUpdate, accountState);
+      ImmutableSet<String> newSecondaryEmails =
+          ImmutableSet.copyOf(accountUpdate.secondaryEmailsModification().apply(secondaryEmails));
+      if (!secondaryEmails.equals(newSecondaryEmails)) {
+        setSecondaryEmails(builder, accountUpdate, accountState, newSecondaryEmails);
+      }
+    }
+
+    private ImmutableSet<String> getSecondaryEmails(
+        TestAccountUpdate accountUpdate, AccountState accountState) {
+      ImmutableSet<String> allEmails =
+          ExternalId.getEmails(accountState.externalIds()).collect(toImmutableSet());
+      if (accountUpdate.preferredEmail().isPresent()) {
+        return ImmutableSet.copyOf(
+            Sets.difference(allEmails, ImmutableSet.of(accountUpdate.preferredEmail().get())));
+      } else if (accountState.account().preferredEmail() != null) {
+        return ImmutableSet.copyOf(
+            Sets.difference(allEmails, ImmutableSet.of(accountState.account().preferredEmail())));
+      }
+      return allEmails;
+    }
+
+    private void setSecondaryEmails(
+        InternalAccountUpdate.Builder builder,
+        TestAccountUpdate accountUpdate,
+        AccountState accountState,
+        ImmutableSet<String> newSecondaryEmails) {
+      // delete all external IDs of SCHEME_MAILTO scheme, then add back SCHEME_MAILTO external IDs
+      // for the new secondary emails and the preferred email
+      builder.deleteExternalIds(
+          accountState.externalIds().stream()
+              .filter(e -> e.isScheme(ExternalId.SCHEME_MAILTO))
+              .collect(toImmutableSet()));
+      builder.addExternalIds(
+          newSecondaryEmails.stream()
+              .map(secondaryEmail -> ExternalId.createEmail(accountId, secondaryEmail))
+              .collect(toImmutableSet()));
+      if (accountUpdate.preferredEmail().isPresent()) {
+        builder.addExternalId(
+            ExternalId.createEmail(accountId, accountUpdate.preferredEmail().get()));
+      } else if (accountState.account().preferredEmail() != null) {
+        builder.addExternalId(
+            ExternalId.createEmail(accountId, accountState.account().preferredEmail()));
+      }
     }
 
     @Override
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
index 2574d55..94b1cc4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
 
@@ -30,6 +32,16 @@
 
   public abstract boolean active();
 
+  public abstract ImmutableSet<String> emails();
+
+  public ImmutableSet<String> secondaryEmails() {
+    if (!preferredEmail().isPresent()) {
+      return emails();
+    }
+
+    return ImmutableSet.copyOf(Sets.difference(emails(), ImmutableSet.of(preferredEmail().get())));
+  }
+
   static Builder builder() {
     return new AutoValue_TestAccount.Builder();
   }
@@ -46,6 +58,8 @@
 
     abstract Builder active(boolean active);
 
+    abstract Builder emails(ImmutableSet<String> emails);
+
     abstract TestAccount build();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index 983fec0..042dc9a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.acceptance.testsuite.account;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
+import java.util.Set;
 
 @AutoValue
 public abstract class TestAccountCreation {
@@ -33,6 +37,8 @@
 
   public abstract Optional<Boolean> active();
 
+  public abstract ImmutableSet<String> secondaryEmails();
+
   abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
   public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
@@ -83,14 +89,29 @@
       return active(false);
     }
 
+    public abstract Builder secondaryEmails(Set<String> secondaryEmails);
+
+    abstract ImmutableSet.Builder<String> secondaryEmailsBuilder();
+
+    public Builder addSecondaryEmail(String secondaryEmail) {
+      secondaryEmailsBuilder().add(secondaryEmail);
+      return this;
+    }
+
     abstract Builder accountCreator(
         ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
     public Account.Id create() {
-      TestAccountCreation accountUpdate = autoBuild();
-      return accountUpdate.accountCreator().applyAndThrowSilently(accountUpdate);
+      TestAccountCreation accountCreation = autoBuild();
+      if (accountCreation.preferredEmail().isPresent()) {
+        checkState(
+            !accountCreation.secondaryEmails().contains(accountCreation.preferredEmail().get()),
+            "preferred email %s cannot be secondary email at the same time",
+            accountCreation.preferredEmail().get());
+      }
+      return accountCreation.accountCreator().applyAndThrowSilently(accountCreation);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
index da599e7..46988eb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
 
 @AutoValue
 public abstract class TestAccountUpdate {
@@ -32,11 +36,14 @@
 
   public abstract Optional<Boolean> active();
 
+  public abstract Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification();
+
   abstract ThrowingConsumer<TestAccountUpdate> accountUpdater();
 
   public static Builder builder(ThrowingConsumer<TestAccountUpdate> accountUpdater) {
     return new AutoValue_TestAccountUpdate.Builder()
         .accountUpdater(accountUpdater)
+        .secondaryEmailsModification(in -> in)
         .httpPassword("http-pass");
   }
 
@@ -82,6 +89,37 @@
       return active(false);
     }
 
+    abstract Builder secondaryEmailsModification(
+        Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification);
+
+    abstract Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification();
+
+    public Builder clearSecondaryEmails() {
+      return secondaryEmailsModification(originalSecondaryEmail -> ImmutableSet.of());
+    }
+
+    public Builder addSecondaryEmail(String secondaryEmail) {
+      Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification =
+          secondaryEmailsModification();
+      secondaryEmailsModification(
+          originalSecondaryEmails ->
+              Sets.union(
+                  secondaryEmailsModification.apply(originalSecondaryEmails),
+                  ImmutableSet.of(secondaryEmail)));
+      return this;
+    }
+
+    public Builder removeSecondaryEmail(String secondaryEmail) {
+      Function<ImmutableSet<String>, Set<String>> previousModification =
+          secondaryEmailsModification();
+      secondaryEmailsModification(
+          originalSecondaryEmails ->
+              Sets.difference(
+                  previousModification.apply(originalSecondaryEmails),
+                  ImmutableSet.of(secondaryEmail)));
+      return this;
+    }
+
     abstract Builder accountUpdater(ThrowingConsumer<TestAccountUpdate> accountUpdater);
 
     abstract TestAccountUpdate autoBuild();
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index 55e0143..053764d 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import java.util.ArrayList;
 import java.util.List;
@@ -36,7 +37,7 @@
 
   protected CommentDetail() {}
 
-  public void include(Change.Id changeId, Comment p) {
+  public void include(Change.Id changeId, HumanComment p) {
     PatchSet.Id psId = PatchSet.id(changeId, p.key.patchSetId);
     if (p.side == 0) {
       if (idA == null && idB.equals(psId)) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index 746a386..62fcfda 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -28,7 +28,8 @@
   V7_4("7.4.*"),
   V7_5("7.5.*"),
   V7_6("7.6.*"),
-  V7_7("7.7.*");
+  V7_7("7.7.*"),
+  V7_8("7.8.*");
 
   private final String version;
   private final Pattern pattern;
diff --git a/java/com/google/gerrit/entities/AttentionSetUpdate.java b/java/com/google/gerrit/entities/AttentionSetUpdate.java
index 45588722..00af7a5 100644
--- a/java/com/google/gerrit/entities/AttentionSetUpdate.java
+++ b/java/com/google/gerrit/entities/AttentionSetUpdate.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import java.time.Instant;
 
 /**
@@ -23,7 +24,7 @@
  * in reverse chronological order. Since each update contains all required information and
  * invalidates all previous state, only the most recent record is relevant for each user.
  *
- * <p>See {@link com.google.gerrit.extensions.api.changes.AddToAttentionSetInput} and {@link
+ * <p>See {@link AttentionSetInput} and {@link
  * com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput} for the representation in
  * the API.
  */
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index b36b5f9..845a9bb 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -39,7 +39,7 @@
  *          |
  *          +- {@link PatchSetApproval}: a +/- vote on the change's current state.
  *          |
- *          +- {@link Comment}: comment about a specific line
+ *          +- {@link HumanComment}: comment about a specific line
  * </pre>
  *
  * <p>
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 9c58fef..2c10c87 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -24,15 +24,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * This class represents inline comments in NoteDb. This means it determines the JSON format for
- * inline comments in the revision notes that NoteDb uses to persist inline comments.
+ * This class is a base class that can be extended by the different types of inline comment
+ * entities.
  *
  * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
  * require a corresponding data migration (adding new optional fields is generally okay).
  *
- * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
+ * <p>Consider updating {@link #getCommentFieldApproximateSize()} when adding/changing fields.
  */
-public class Comment {
+public abstract class Comment {
   public enum Status {
     DRAFT('d'),
 
@@ -301,11 +301,13 @@
    * Returns the comment's approximate size. This is used to enforce size limits and should
    * therefore include all unbounded fields (e.g. String-s).
    */
-  public int getApproximateSize() {
+  protected int getCommentFieldApproximateSize() {
     return nullableLength(message, parentUuid, tag, revId, serverId)
         + (key != null ? nullableLength(key.filename, key.uuid) : 0);
   }
 
+  public abstract int getApproximateSize();
+
   static int nullableLength(String... strings) {
     int length = 0;
     for (String s : strings) {
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
new file mode 100644
index 0000000..8b687cc
--- /dev/null
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.sql.Timestamp;
+
+/**
+ * This class represents inline human comments in NoteDb. This means it determines the JSON format
+ * for inline comments in the revision notes that NoteDb uses to persist inline comments.
+ *
+ * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
+ * require a corresponding data migration (adding new optional fields is generally okay).
+ *
+ * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
+ */
+public class HumanComment extends Comment {
+
+  public HumanComment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      boolean unresolved) {
+    super(key, author, writtenOn, side, message, serverId, unresolved);
+  }
+
+  public HumanComment(HumanComment comment) {
+    super(comment);
+  }
+
+  @Override
+  public int getApproximateSize() {
+    return super.getCommentFieldApproximateSize();
+  }
+
+  @Override
+  public String toString() {
+    return toStringHelper().toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof HumanComment)) {
+      return false;
+    }
+    return super.equals(o);
+  }
+
+  @Override
+  public int hashCode() {
+    return super.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 3926d47..e6b2167 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
 /**
@@ -107,6 +108,16 @@
     public char getCode() {
       return code;
     }
+
+    @UsedAt(UsedAt.Project.COLLABNET)
+    public static ChangeType forCode(char c) {
+      for (ChangeType s : ChangeType.values()) {
+        if (s.code == c) {
+          return s;
+        }
+      }
+      return null;
+    }
   }
 
   /** Type of formatting for this patch. */
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index 9256e79..03ddad5 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -42,7 +42,8 @@
 
   @Override
   public int getApproximateSize() {
-    int approximateSize = super.getApproximateSize() + nullableLength(robotId, robotRunId, url);
+    int approximateSize =
+        super.getCommentFieldApproximateSize() + nullableLength(robotId, robotRunId, url);
     approximateSize +=
         properties != null
             ? properties.entrySet().stream()
@@ -66,4 +67,23 @@
         .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
         .toString();
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof RobotComment)) {
+      return false;
+    }
+    RobotComment c = (RobotComment) o;
+    return super.equals(o)
+        && Objects.equals(robotId, c.robotId)
+        && Objects.equals(robotRunId, c.robotRunId)
+        && Objects.equals(url, c.url)
+        && Objects.equals(properties, c.properties)
+        && Objects.equals(fixSuggestions, c.fixSuggestions);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), robotId, robotRunId, url, properties, fixSuggestions);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
similarity index 87%
rename from java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
rename to java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
index 39efc64..1a173e9 100644
--- a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
@@ -20,14 +20,14 @@
  * @see RemoveFromAttentionSetInput
  * @see com.google.gerrit.extensions.common.AttentionSetEntry
  */
-public class AddToAttentionSetInput {
+public class AttentionSetInput {
   public String user;
   public String reason;
 
-  public AddToAttentionSetInput(String user, String reason) {
+  public AttentionSetInput(String user, String reason) {
     this.user = user;
     this.reason = reason;
   }
 
-  public AddToAttentionSetInput() {}
+  public AttentionSetInput() {}
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 8df5343..e8b58f9 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -302,7 +302,7 @@
   AttentionSetApi attention(String id) throws RestApiException;
 
   /** Adds a user to the attention set. */
-  AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException;
+  AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException;
 
   /** Set the assignee of a change. */
   AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
@@ -578,7 +578,7 @@
     }
 
     @Override
-    public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+    public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 69c1790..fb03bc5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -24,7 +24,7 @@
   public String base;
   public Integer parent;
 
-  public NotifyHandling notify = NotifyHandling.NONE;
+  public NotifyHandling notify = NotifyHandling.ALL;
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public boolean keepReviewers;
diff --git a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
index 9212788..b14c53d 100644
--- a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
@@ -19,7 +19,7 @@
 /**
  * Input at API level to remove a user from the attention set.
  *
- * @see AddToAttentionSetInput
+ * @see AttentionSetInput
  * @see com.google.gerrit.extensions.common.AttentionSetEntry
  */
 public class RemoveFromAttentionSetInput {
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index 4dea42f..dba2eee 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -48,9 +48,9 @@
     return r;
   }
 
-  static String toHex(Set<ListChangesOption> options) {
+  static <T extends Enum<T> & ListOption> String toHex(Set<T> options) {
     int v = 0;
-    for (ListChangesOption option : options) {
+    for (T option : options) {
       v |= 1 << option.getValue();
     }
 
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index 9d171d5..1c3cafe 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.StopPluginListener;
 import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
 import com.google.inject.Singleton;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.ServletModule;
@@ -32,11 +34,15 @@
 
 /** Filters all HTTP requests passing through the server. */
 public abstract class AllRequestFilter implements Filter {
-  public static ServletModule module() {
+  public static Module module() {
     return new ServletModule() {
       @Override
       protected void configureServlets() {
         DynamicSet.setOf(binder(), AllRequestFilter.class);
+        DynamicSet.bind(binder(), AllRequestFilter.class)
+            .to(AllowRenderInFrameFilter.class)
+            .in(Scopes.SINGLETON);
+
         filter("/*").through(FilterProxy.class);
 
         bind(StopPluginListener.class)
diff --git a/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java b/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java
new file mode 100644
index 0000000..b414aad
--- /dev/null
+++ b/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+
+public class AllowRenderInFrameFilter extends AllRequestFilter {
+  static final String X_FRAME_OPTIONS_HEADER_NAME = "X-Frame-Options";
+
+  public static enum XFrameOption {
+    ALLOW,
+    SAMEORIGIN;
+  }
+
+  private final String xframeOptionString;
+  private final boolean skipXFrameOption;
+
+  @Inject
+  public AllowRenderInFrameFilter(@GerritServerConfig Config cfg) {
+    XFrameOption xframeOption =
+        cfg.getEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+    boolean canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
+    xframeOptionString = canLoadInIFrame ? xframeOption.name() : "DENY";
+
+    skipXFrameOption = xframeOption.equals(XFrameOption.ALLOW) && canLoadInIFrame;
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    if (skipXFrameOption) {
+      chain.doFilter(request, response);
+    } else {
+      HttpServletResponse httpResponse = (HttpServletResponse) response;
+      httpResponse.addHeader(X_FRAME_OPTIONS_HEADER_NAME, xframeOptionString);
+      chain.doFilter(request, httpResponse);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 41d2f83..cddaea4 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -153,8 +153,7 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
     } catch (AuthException e) {
-      logger.atFine().withCause(e).log(
-          "Can't inline account-related data because user is unauthenticated");
+      logger.atFine().log("Can't inline account-related data because user is unauthenticated");
       // Don't render data
     }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 89ebdc1..f743578 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -601,6 +601,7 @@
         }
       } catch (MalformedJsonException | JsonParseException e) {
         cause = Optional.of(e);
+        logger.atFine().withCause(e).log("REST call failed on JSON parsing");
         responseBytes =
             replyError(
                 req, res, statusCode = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 0aa374b..3aa9de0 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -176,6 +177,33 @@
     return true;
   }
 
+  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+    if (skipFields.contains(f.getName())) {
+      return null;
+    }
+
+    Object v;
+    try {
+      v = f.get(obj);
+    } catch (StorageException e) {
+      // StorageException is thrown when the object is not found. On this case,
+      // it is pointless to make further attempts for each field, so propagate
+      // the exception to return an empty list.
+      logger.atSevere().withCause(e).log("error getting field %s of %s", f.getName(), obj);
+      throw e;
+    } catch (RuntimeException e) {
+      logger.atSevere().withCause(e).log("error getting field %s of %s", f.getName(), obj);
+      return null;
+    }
+    if (v == null) {
+      return null;
+    } else if (f.isRepeatable()) {
+      return new Values<>(f, (Iterable<?>) v);
+    } else {
+      return new Values<>(f, Collections.singleton(v));
+    }
+  }
+
   /**
    * Build all fields in the schema from an input object.
    *
@@ -186,31 +214,14 @@
    * @return all non-null field values from the object.
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
-    return fields.values().stream()
-        .map(
-            f -> {
-              if (skipFields.contains(f.getName())) {
-                return null;
-              }
-
-              Object v;
-              try {
-                v = f.get(obj);
-              } catch (RuntimeException e) {
-                logger.atSevere().withCause(e).log(
-                    "error getting field %s of %s", f.getName(), obj);
-                return null;
-              }
-              if (v == null) {
-                return null;
-              } else if (f.isRepeatable()) {
-                return new Values<>(f, (Iterable<?>) v);
-              } else {
-                return new Values<>(f, Collections.singleton(v));
-              }
-            })
-        .filter(Objects::nonNull)
-        .collect(toImmutableList());
+    try {
+      return fields.values().stream()
+          .map(f -> fieldValues(obj, f, skipFields))
+          .filter(Objects::nonNull)
+          .collect(toImmutableList());
+    } catch (StorageException e) {
+      return ImmutableList.of();
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index ecfc7bd..32b4b21 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -83,7 +83,7 @@
   }
 
   protected PrintWriter newPrintWriter(OutputStream out) {
-    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8), true);
   }
 
   private static class ErrorListener implements Runnable {
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 7905a0a..2fc659d 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -60,7 +60,7 @@
    * @return list of MailComments parsed from the html part of the email
    */
   public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
+      MailMessage email, Collection<HumanComment> comments, String changeUrl) {
     // TODO(hiesel) Add support for Gmail Mobile
     // TODO(hiesel) Add tests for other popular email clients
 
@@ -71,10 +71,10 @@
     // Gerrit as these are generally more reliable then the text captions.
     List<MailComment> parsedComments = new ArrayList<>();
     Document d = Jsoup.parse(email.htmlContent());
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+    PeekingIterator<HumanComment> iter = Iterators.peekingIterator(comments.iterator());
 
     String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
+    HumanComment lastEncounteredComment = null;
     for (Element e : d.body().getAllElements()) {
       String elementName = e.tagName();
       boolean isInBlockQuote =
@@ -91,7 +91,7 @@
         if (!iter.hasNext()) {
           continue;
         }
-        Comment perspectiveComment = iter.peek();
+        HumanComment perspectiveComment = iter.peek();
         if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
           if (lastEncounteredFileName == null
               || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
diff --git a/java/com/google/gerrit/mail/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
index f024f17..3e7da10 100644
--- a/java/com/google/gerrit/mail/MailComment.java
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.Objects;
 
 /** A comment parsed from inbound email */
@@ -26,7 +26,7 @@
   }
 
   CommentType type;
-  Comment inReplyTo;
+  HumanComment inReplyTo;
   String fileName;
   String message;
   boolean isLink;
@@ -34,7 +34,7 @@
   public MailComment() {}
 
   public MailComment(
-      String message, String fileName, Comment inReplyTo, CommentType type, boolean isLink) {
+      String message, String fileName, HumanComment inReplyTo, CommentType type, boolean isLink) {
     this.message = message;
     this.fileName = fileName;
     this.inReplyTo = inReplyTo;
@@ -46,7 +46,7 @@
     return type;
   }
 
-  public Comment getInReplyTo() {
+  public HumanComment getInReplyTo() {
     return inReplyTo;
   }
 
diff --git a/java/com/google/gerrit/mail/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
index dac3deb..a33c66f 100644
--- a/java/com/google/gerrit/mail/TextParser.java
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -31,15 +31,15 @@
    * Parses comments from plaintext email.
    *
    * @param email @param email the message as received from the email service
-   * @param comments list of {@link Comment}s previously persisted on the change that caused the
-   *     original notification email to be sent out. Ordering must be the same as in the outbound
-   *     email
+   * @param comments list of {@link HumanComment}s previously persisted on the change that caused
+   *     the original notification email to be sent out. Ordering must be the same as in the
+   *     outbound email
    * @param changeUrl canonical change url that points to the change on this Gerrit instance.
    *     Example: https://go-review.googlesource.com/#/c/91570
    * @return list of MailComments parsed from the plaintext part of the email
    */
   public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
+      MailMessage email, Collection<HumanComment> comments, String changeUrl) {
     String body = email.textContent();
     // Replace CR-LF by \n
     body = body.replace("\r\n", "\n");
@@ -62,11 +62,11 @@
       body = body.replace(doubleQuotePattern, singleQuotePattern);
     }
 
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+    PeekingIterator<HumanComment> iter = Iterators.peekingIterator(comments.iterator());
 
     MailComment currentComment = null;
     String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
+    HumanComment lastEncounteredComment = null;
     for (String line : Splitter.on('\n').split(body)) {
       if (line.equals(">")) {
         // Skip empty lines
@@ -89,7 +89,7 @@
         if (!iter.hasNext()) {
           continue;
         }
-        Comment perspectiveComment = iter.peek();
+        HumanComment perspectiveComment = iter.peek();
         if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
           if (lastEncounteredFileName == null
               || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 2e526bb..966801f 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -202,6 +202,9 @@
     if (result.success()) {
       index.markReady(true);
     }
+    System.out.format(
+        "Index %s in version %d is %sready\n",
+        def.getName(), index.getSchema().getVersion(), result.success() ? "" : "NOT ");
     return result.success();
   }
 }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index e8b44aa..0b3d1cb 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
@@ -116,7 +117,7 @@
     this.serverId = serverId;
   }
 
-  public Comment newComment(
+  public HumanComment newHumanComment(
       ChangeContext ctx,
       String path,
       PatchSet.Id psId,
@@ -132,15 +133,15 @@
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
         Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
-        Optional<Comment> parent = getPublished(ctx.getNotes(), key);
+        Optional<HumanComment> parent = getPublishedHumanComment(ctx.getNotes(), key);
         if (!parent.isPresent()) {
           throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
         }
         unresolved = parent.get().unresolved;
       }
     }
-    Comment c =
-        new Comment(
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
             ctx.getUser().getAccountId(),
             ctx.getWhen(),
@@ -175,19 +176,21 @@
     return c;
   }
 
-  public Optional<Comment> getPublished(ChangeNotes notes, Comment.Key key) {
-    return publishedByChange(notes).stream().filter(c -> key.equals(c.key)).findFirst();
+  public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, Comment.Key key) {
+    return publishedHumanCommentsByChange(notes).stream()
+        .filter(c -> key.equals(c.key))
+        .findFirst();
   }
 
-  public Optional<Comment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
+  public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
     return draftByChangeAuthor(notes, user.getAccountId()).stream()
         .filter(c -> key.equals(c.key))
         .findFirst();
   }
 
-  public List<Comment> publishedByChange(ChangeNotes notes) {
+  public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
     notes.load();
-    return sort(Lists.newArrayList(notes.getComments().values()));
+    return sort(Lists.newArrayList(notes.getHumanComments().values()));
   }
 
   public List<RobotComment> robotCommentsByChange(ChangeNotes notes) {
@@ -195,8 +198,8 @@
     return sort(Lists.newArrayList(notes.getRobotComments().values()));
   }
 
-  public List<Comment> draftByChange(ChangeNotes notes) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> draftByChange(ChangeNotes notes) {
+    List<HumanComment> comments = new ArrayList<>();
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
       Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
@@ -206,8 +209,8 @@
     return sort(comments);
   }
 
-  public List<Comment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    List<HumanComment> comments = new ArrayList<>();
     comments.addAll(publishedByPatchSet(notes, psId));
 
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
@@ -219,13 +222,13 @@
     return sort(comments);
   }
 
-  public List<Comment> publishedByChangeFile(ChangeNotes notes, String file) {
-    return commentsOnFile(notes.load().getComments().values(), file);
+  public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
+    return commentsOnFile(notes.load().getHumanComments().values(), file);
   }
 
-  public List<Comment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+  public List<HumanComment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     return removeCommentsOnAncestorOfCommitMessage(
-        commentsOnPatchSet(notes.load().getComments().values(), psId));
+        commentsOnPatchSet(notes.load().getHumanComments().values(), psId));
   }
 
   public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
@@ -288,29 +291,31 @@
    * auto-merge was done. From that time there may still be comments on the auto-merge commit
    * message and those we want to filter out.
    */
-  private List<Comment> removeCommentsOnAncestorOfCommitMessage(List<Comment> list) {
+  private List<HumanComment> removeCommentsOnAncestorOfCommitMessage(List<HumanComment> list) {
     return list.stream()
         .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
         .collect(toList());
   }
 
-  public List<Comment> draftByPatchSetAuthor(
+  public List<HumanComment> draftByPatchSetAuthor(
       PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
     return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
   }
 
-  public List<Comment> draftByChangeFileAuthor(ChangeNotes notes, String file, Account.Id author) {
+  public List<HumanComment> draftByChangeFileAuthor(
+      ChangeNotes notes, String file, Account.Id author) {
     return commentsOnFile(notes.load().getDraftComments(author).values(), file);
   }
 
-  public List<Comment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
+    List<HumanComment> comments = new ArrayList<>();
     comments.addAll(notes.getDraftComments(author).values());
     return sort(comments);
   }
 
-  public void putComments(ChangeUpdate update, Comment.Status status, Iterable<Comment> comments) {
-    for (Comment c : comments) {
+  public void putHumanComments(
+      ChangeUpdate update, HumanComment.Status status, Iterable<HumanComment> comments) {
+    for (HumanComment c : comments) {
       update.putComment(status, c);
     }
   }
@@ -321,8 +326,8 @@
     }
   }
 
-  public void deleteComments(ChangeUpdate update, Iterable<Comment> comments) {
-    for (Comment c : comments) {
+  public void deleteHumanComments(ChangeUpdate update, Iterable<HumanComment> comments) {
+    for (HumanComment c : comments) {
       update.deleteComment(c);
     }
   }
@@ -332,9 +337,10 @@
     update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
   }
 
-  private static List<Comment> commentsOnFile(Collection<Comment> allComments, String file) {
-    List<Comment> result = new ArrayList<>(allComments.size());
-    for (Comment c : allComments) {
+  private static List<HumanComment> commentsOnFile(
+      Collection<HumanComment> allComments, String file) {
+    List<HumanComment> result = new ArrayList<>(allComments.size());
+    for (HumanComment c : allComments) {
       String currentFilename = c.key.filename;
       if (currentFilename.equals(file)) {
         result.add(c);
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 3d34d6b..658af15 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -20,8 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.Comment.Status;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
@@ -60,7 +59,7 @@
   public void publish(
       ChangeContext ctx,
       ChangeUpdate changeUpdate,
-      Collection<Comment> draftComments,
+      Collection<HumanComment> draftComments,
       @Nullable String tag) {
     ChangeNotes notes = ctx.getNotes();
     checkArgument(notes != null);
@@ -70,8 +69,8 @@
 
     Map<PatchSet.Id, PatchSet> patchSets =
         psUtil.getAsMap(notes, draftComments.stream().map(d -> psId(notes, d)).collect(toSet()));
-    Set<Comment> commentsToPublish = new HashSet<>();
-    for (Comment draftComment : draftComments) {
+    Set<HumanComment> commentsToPublish = new HashSet<>();
+    for (HumanComment draftComment : draftComments) {
       PatchSet.Id psIdOfDraftComment = psId(notes, draftComment);
       PatchSet ps = patchSets.get(psIdOfDraftComment);
       if (ps == null) {
@@ -109,10 +108,10 @@
       }
       commentsToPublish.add(draftComment);
     }
-    commentsUtil.putComments(changeUpdate, Status.PUBLISHED, commentsToPublish);
+    commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, commentsToPublish);
   }
 
-  private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
+  private static PatchSet.Id psId(ChangeNotes notes, HumanComment c) {
     return PatchSet.id(notes.getChangeId(), c.key.patchSetId);
   }
 
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 745755b..358ce92 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -16,7 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -58,7 +58,7 @@
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
 
-  private List<Comment> comments = new ArrayList<>();
+  private List<HumanComment> comments = new ArrayList<>();
   private ChangeMessage message;
   private IdentifiedUser user;
 
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 5122f8a..b4a5da7 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
@@ -543,7 +543,7 @@
   }
 
   @Override
-  public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+  public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
     try {
       return addToAttentionSet.apply(change, input).value();
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index c5fcab1..35dd9c1 100644
--- a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.restapi.change.DeleteComment;
 import com.google.gerrit.server.restapi.change.GetComment;
 import com.google.inject.Inject;
@@ -28,16 +28,16 @@
 
 class CommentApiImpl implements CommentApi {
   interface Factory {
-    CommentApiImpl create(CommentResource c);
+    CommentApiImpl create(HumanCommentResource c);
   }
 
   private final GetComment getComment;
   private final DeleteComment deleteComment;
-  private final CommentResource comment;
+  private final HumanCommentResource comment;
 
   @Inject
   CommentApiImpl(
-      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
+      GetComment getComment, DeleteComment deleteComment, @Assisted HumanCommentResource comment) {
     this.getComment = getComment;
     this.deleteComment = deleteComment;
     this.comment = comment;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 829c290..2bc0773 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
@@ -36,24 +35,36 @@
 public class AddToAttentionSetOp implements BatchUpdateOp {
 
   public interface Factory {
-    AddToAttentionSetOp create(Account.Id attentionUserId, String reason);
+    AddToAttentionSetOp create(
+        Account.Id attentionUserId, String reason, boolean withChangeMessage);
   }
 
   private final ChangeData.Factory changeDataFactory;
   private final ChangeMessagesUtil cmUtil;
   private final Account.Id attentionUserId;
   private final String reason;
+  private final boolean withChangeMessage;
 
+  /**
+   * Add a specified user to the attention set.
+   *
+   * @param attentionUserId the id of the user we want to add to the attention set.
+   * @param reason The reason for adding that user.
+   * @param withChangeMessage Whether or not we wish to add a change message detailing about adding
+   *     that user to the attention set.
+   */
   @Inject
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
       @Assisted Account.Id attentionUserId,
-      @Assisted String reason) {
+      @Assisted String reason,
+      @Assisted boolean withChangeMessage) {
     this.changeDataFactory = changeDataFactory;
     this.cmUtil = cmUtil;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
+    this.withChangeMessage = withChangeMessage;
   }
 
   @Override
@@ -68,15 +79,16 @@
     }
 
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.setAttentionSetUpdates(
-        ImmutableSet.of(
-            AttentionSetUpdate.createForWrite(
-                attentionUserId, AttentionSetUpdate.Operation.ADD, reason)));
-    addMessage(ctx, update);
+    update.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
+    if (withChangeMessage) {
+      addChangeMessage(ctx, update);
+    }
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addChangeMessage(ChangeContext ctx, ChangeUpdate update) {
     String message = "Added to attention set: " + attentionUserId;
     cmUtil.addChangeMessage(
         update,
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 3d3e8f9..e0648cf 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -27,9 +27,9 @@
       new TypeLiteral<RestView<DraftCommentResource>>() {};
 
   private final RevisionResource rev;
-  private final Comment comment;
+  private final HumanComment comment;
 
-  public DraftCommentResource(RevisionResource rev, Comment c) {
+  public DraftCommentResource(RevisionResource rev, HumanComment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -46,7 +46,7 @@
     return rev.getPatchSet();
   }
 
-  public Comment getComment() {
+  public HumanComment getComment() {
     return comment;
   }
 
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f1eff6f..b30a6a3 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -65,7 +65,7 @@
         PatchSet patchSet,
         IdentifiedUser user,
         ChangeMessage message,
-        List<Comment> comments,
+        List<? extends Comment> comments,
         String patchSetComment,
         List<LabelVote> labels,
         RepoView repoView);
@@ -82,7 +82,7 @@
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final ChangeMessage message;
-  private final List<Comment> comments;
+  private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
   private final RepoView repoView;
@@ -99,7 +99,7 @@
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted ChangeMessage message,
-      @Assisted List<Comment> comments,
+      @Assisted List<? extends Comment> comments,
       @Nullable @Assisted String patchSetComment,
       @Assisted List<LabelVote> labels,
       @Assisted RepoView repoView) {
diff --git a/java/com/google/gerrit/server/change/CommentResource.java b/java/com/google/gerrit/server/change/HumanCommentResource.java
similarity index 76%
rename from java/com/google/gerrit/server/change/CommentResource.java
rename to java/com/google/gerrit/server/change/HumanCommentResource.java
index dbe7a76..1611aaa 100644
--- a/java/com/google/gerrit/server/change/CommentResource.java
+++ b/java/com/google/gerrit/server/change/HumanCommentResource.java
@@ -15,20 +15,20 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
 
-public class CommentResource implements RestResource {
-  public static final TypeLiteral<RestView<CommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<CommentResource>>() {};
+public class HumanCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<HumanCommentResource>> COMMENT_KIND =
+      new TypeLiteral<RestView<HumanCommentResource>>() {};
 
   private final RevisionResource rev;
-  private final Comment comment;
+  private final HumanComment comment;
 
-  public CommentResource(RevisionResource rev, Comment c) {
+  public HumanCommentResource(RevisionResource rev, HumanComment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -37,7 +37,7 @@
     return rev.getPatchSet();
   }
 
-  public Comment getComment() {
+  public HumanComment getComment() {
     return comment;
   }
 
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 07f0d78..4680629 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
@@ -36,24 +35,36 @@
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
 
   public interface Factory {
-    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason);
+    RemoveFromAttentionSetOp create(
+        Account.Id attentionUserId, String reason, boolean withChangeMessage);
   }
 
   private final ChangeData.Factory changeDataFactory;
   private final ChangeMessagesUtil cmUtil;
   private final Account.Id attentionUserId;
   private final String reason;
+  private final boolean withChangeMessage;
 
+  /**
+   * Remove a specified user from the attention set.
+   *
+   * @param attentionUserId the id of the user we want to add to the attention set.
+   * @param reason The reason for adding that user.
+   * @param withChangeMessage Whether or not we wish to add a change message detailing about adding
+   *     that user to the attention set.
+   */
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
       @Assisted Account.Id attentionUserId,
-      @Assisted String reason) {
+      @Assisted String reason,
+      @Assisted boolean withChangeMessage) {
     this.changeDataFactory = changeDataFactory;
     this.cmUtil = cmUtil;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
+    this.withChangeMessage = withChangeMessage;
   }
 
   @Override
@@ -68,14 +79,15 @@
     }
 
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.setAttentionSetUpdates(
-        ImmutableSet.of(
-            AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason)));
-    addMessage(ctx, update);
+    update.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
+    if (withChangeMessage) {
+      addChangeMessage(ctx, update);
+    }
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addChangeMessage(ChangeContext ctx, ChangeUpdate update) {
     String message = "Removed from attention set: " + attentionUserId;
     cmUtil.addChangeMessage(
         update,
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index f0da560..19d2f3d 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.UserIdentity;
@@ -380,8 +380,8 @@
   }
 
   public void addPatchSetComments(
-      PatchSetAttribute patchSetAttribute, Collection<Comment> comments) {
-    for (Comment comment : comments) {
+      PatchSetAttribute patchSetAttribute, Collection<HumanComment> comments) {
+    for (HumanComment comment : comments) {
       if (comment.key.patchSetId == patchSetAttribute.number) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
@@ -547,7 +547,7 @@
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(HumanComment c) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
     a.reviewer = asAccountAttribute(c.author.getId());
     a.file = c.key.filename;
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 404fa3c..5436db7 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -69,7 +69,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.Project;
@@ -2022,7 +2022,7 @@
       }
 
       if (magicBranch != null && magicBranch.shouldPublishComments()) {
-        List<Comment> drafts =
+        List<HumanComment> drafts =
             commentsUtil.draftByChangeAuthor(
                 notesFactory.createChecked(change), user.getAccountId());
         ImmutableList<CommentForValidation> draftsForValidation =
@@ -2144,9 +2144,10 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            for (Ref ref : existingRefs) {
-              updateGroups.add(new UpdateGroupsRequest(ref, c));
-            }
+            existingRefs.stream()
+                .map(r -> PatchSet.Id.fromRef(r.getName()))
+                .filter(Objects::nonNull)
+                .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
             }
@@ -3019,8 +3020,8 @@
     final RevCommit commit;
     List<String> groups = ImmutableList.of();
 
-    UpdateGroupsRequest(Ref ref, RevCommit commit) {
-      this.psId = requireNonNull(PatchSet.Id.fromRef(ref.getName()));
+    UpdateGroupsRequest(PatchSet.Id psId, RevCommit commit) {
+      this.psId = psId;
       this.commit = commit;
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
index 67aa3bd..a554f90 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
@@ -45,7 +45,7 @@
     ChangeNotes notes =
         notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
     int numExistingCommentsAndChangeMessages =
-        notes.getComments().size()
+        notes.getHumanComments().size()
             + notes.getRobotComments().size()
             + notes.getChangeMessages().size();
     if (!comments.isEmpty()
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d9a1420..d507531 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -51,7 +51,7 @@
         notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
     int existingCumulativeSize =
         Stream.concat(
-                    notes.getComments().values().stream(),
+                    notes.getHumanComments().values().stream(),
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 2a8a5ba..e9349c4 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -43,10 +42,9 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import java.util.SortedSet;
-import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -62,6 +60,7 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
@@ -86,22 +85,27 @@
     this.projectCache = projectCache;
   }
 
-  private static class ProjectHolder implements Comparable<ProjectHolder> {
-    final Project.NameKey name;
-    private final long size;
+  private static class ProjectSlice {
+    private final Project.NameKey name;
+    private final int slice;
+    private final int slices;
 
-    ProjectHolder(Project.NameKey name, long size) {
+    ProjectSlice(Project.NameKey name, int slice, int slices) {
       this.name = name;
-      this.size = size;
+      this.slice = slice;
+      this.slices = slices;
     }
 
-    @Override
-    public int compareTo(ProjectHolder other) {
-      // Sort projects based on size first to maximize utilization of threads early on.
-      return ComparisonChain.start()
-          .compare(other.size, size)
-          .compare(other.name.get(), name.get())
-          .result();
+    public Project.NameKey getName() {
+      return name;
+    }
+
+    public int getSlice() {
+      return slice;
+    }
+
+    public int getSlices() {
+      return slices;
     }
   }
 
@@ -109,19 +113,39 @@
   public Result indexAll(ChangeIndex index) {
     ProgressMonitor pm = new TextProgressMonitor();
     pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    SortedSet<ProjectHolder> projects = new TreeSet<>();
+    List<ProjectSlice> projectSlices = new ArrayList<>();
     int changeCount = 0;
     Stopwatch sw = Stopwatch.createStarted();
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
+        // The simplest approach to distribute indexing would be to let each thread grab a project
+        // and index it fully. But if a site has one big project and 100s of small projects, then
+        // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
+        // projects have been reindexed, and only the thread that reindexes the big project is
+        // still working. The other threads would idle. Reindexing the big project on a single
+        // thread becomes the critical path. Bringing in more CPUs would not speed up things.
+        //
+        // To avoid such situations, we split big repos into smaller parts and let
+        // the thread pool index these smaller parts. This splitting introduces an overhead in the
+        // workload setup and there might be additional slow-downs from multiple threads
+        // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
+        // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
+        // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
+        // in 2020.
         int size = estimateSize(repo);
         changeCount += size;
-        projects.add(new ProjectHolder(name, size));
+        int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
+        if (slices > 1) {
+          verboseWriter.println("Submitting " + name + " for indexing in " + slices + " slices");
+        }
+        for (int slice = 0; slice < slices; slice++) {
+          projectSlices.add(new ProjectSlice(name, slice, slices));
+        }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Error collecting project %s", name);
         projectsFailed++;
-        if (projectsFailed > projects.size() / 2) {
+        if (projectsFailed > projectCache.all().size() / 2) {
           logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
           return Result.create(sw, false, 0, 0);
         }
@@ -130,7 +154,15 @@
     }
     pm.endTask();
     setTotalWork(changeCount);
-    return indexAll(index, projects);
+
+    // projectSlices are currently grouped by projects. First all slices for project1, followed
+    // by all slices for project2, and so on. As workers pick tasks sequentially, multiple threads
+    // would typically work concurrently on different slices of the same project. While this is not
+    // a big issue, shuffling the list beforehand helps with ungrouping the project slices, so
+    // different slices are less likely to be worked on concurrently.
+    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
+    Collections.shuffle(projectSlices);
+    return indexAll(index, projectSlices);
   }
 
   private int estimateSize(Repository repo) throws IOException {
@@ -146,10 +178,10 @@
     return Ints.saturatedCast(size);
   }
 
-  private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
+  private SiteIndexer.Result indexAll(ChangeIndex index, List<ProjectSlice> projectSlices) {
     Stopwatch sw = Stopwatch.createStarted();
     MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("projects", projects.size());
+    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
     checkState(totalWork >= 0);
     Task doneTask = mpm.beginSubTask(null, totalWork);
     Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
@@ -157,12 +189,21 @@
     List<ListenableFuture<?>> futures = new ArrayList<>();
     AtomicBoolean ok = new AtomicBoolean(true);
 
-    for (ProjectHolder project : projects) {
+    for (ProjectSlice projectSlice : projectSlices) {
+      Project.NameKey name = projectSlice.getName();
+      int slice = projectSlice.getSlice();
+      int slices = projectSlice.getSlices();
       ListenableFuture<?> future =
           executor.submit(
               reindexProject(
-                  indexerFactory.create(executor, index), project.name, doneTask, failedTask));
-      addErrorListener(future, "project " + project.name, projTask, ok);
+                  indexerFactory.create(executor, index),
+                  name,
+                  slice,
+                  slices,
+                  doneTask,
+                  failedTask));
+      String description = "project " + name + " (" + slice + "/" + slices + ")";
+      addErrorListener(future, description, projTask, ok);
       futures.add(future);
     }
 
@@ -197,22 +238,38 @@
 
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
-    return new ProjectIndexer(indexer, project, done, failed);
+    return reindexProject(indexer, project, 0, 1, done, failed);
+  }
+
+  public Callable<Void> reindexProject(
+      ChangeIndexer indexer,
+      Project.NameKey project,
+      int slice,
+      int slices,
+      Task done,
+      Task failed) {
+    return new ProjectIndexer(indexer, project, slice, slices, done, failed);
   }
 
   private class ProjectIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
     private final Project.NameKey project;
+    private final int slice;
+    private final int slices;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
     private ProjectIndexer(
         ChangeIndexer indexer,
         Project.NameKey project,
+        int slice,
+        int slices,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
       this.project = project;
+      this.slice = slice;
+      this.slices = slices;
       this.done = done;
       this.failed = failed;
     }
@@ -227,7 +284,7 @@
         // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
         // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
         // we don't have concrete proof that improving packfile locality would help.
-        notesFactory.scan(repo, project).forEach(r -> index(r));
+        notesFactory.scan(repo, project, id -> (id.get() % slices) == slice).forEach(r -> index(r));
       } catch (RepositoryNotFoundException rnfe) {
         logger.atSevere().log(rnfe.getMessage());
       } finally {
@@ -244,7 +301,8 @@
       try {
         indexer.index(changeDataFactory.create(r.notes()));
         done.update(1);
-        verboseWriter.println("Reindexed change " + r.id());
+        verboseWriter.format(
+            "Reindexed change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
       } catch (RejectedExecutionException e) {
         // Server shutdown, don't spam the logs.
         failSilently();
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index f004e4b..b38bef6 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -257,7 +257,7 @@
       // Get all comments; filter and sort them to get the original list of
       // comments from the outbound email.
       // TODO(hiesel) Also filter by original comment author.
-      Collection<Comment> comments =
+      Collection<HumanComment> comments =
           cd.publishedComments().stream()
               .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
               .sorted(CommentsUtil.COMMENT_ORDER)
@@ -319,7 +319,7 @@
     private final List<MailComment> parsedComments;
     private final String tag;
     private ChangeMessage changeMessage;
-    private List<Comment> comments;
+    private List<HumanComment> comments;
     private PatchSet patchSet;
     private ChangeNotes notes;
 
@@ -349,8 +349,10 @@
         comments.add(
             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
-      commentsUtil.putComments(
-          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Comment.Status.PUBLISHED, comments);
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+          HumanComment.Status.PUBLISHED,
+          comments);
 
       return true;
     }
@@ -416,7 +418,7 @@
       return current;
     }
 
-    private Comment persistentCommentFromMailComment(
+    private HumanComment persistentCommentFromMailComment(
         ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
         throws UnprocessableEntityException, PatchListNotAvailableException {
       String fileName;
@@ -431,8 +433,8 @@
         side = Side.REVISION;
       }
 
-      Comment comment =
-          commentsUtil.newComment(
+      HumanComment comment =
+          commentsUtil.newHumanComment(
               ctx,
               fileName,
               patchSetForComment.id(),
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index f3cccf2..0ba5da51 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.KeyUtil;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
@@ -114,7 +115,7 @@
     }
   }
 
-  private List<Comment> inlineComments = Collections.emptyList();
+  private List<? extends Comment> inlineComments = Collections.emptyList();
   private String patchSetComment;
   private List<LabelVote> labels = Collections.emptyList();
   private final CommentsUtil commentsUtil;
@@ -136,7 +137,7 @@
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
   }
 
-  public void setComments(List<Comment> comments) {
+  public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
@@ -244,23 +245,21 @@
   /** Get the set of accounts whose comments have been replied to in this email. */
   private HashSet<Account.Id> getReplyAccounts() {
     HashSet<Account.Id> replyAccounts = new HashSet<>();
-
     // Track visited parent UUIDs to avoid cycles.
     HashSet<String> visitedUuids = new HashSet<>();
 
     for (Comment comment : inlineComments) {
       visitedUuids.add(comment.key.uuid);
-
       // Traverse the parent relation to the top of the comment thread.
       Comment current = comment;
       while (current.parentUuid != null && !visitedUuids.contains(current.parentUuid)) {
-        Optional<Comment> optParent = getParent(current);
+        Optional<HumanComment> optParent = getParent(current);
         if (!optParent.isPresent()) {
           // There is a parent UUID, but it cannot be loaded, break from the comment thread.
           break;
         }
 
-        Comment parent = optParent.get();
+        HumanComment parent = optParent.get();
         replyAccounts.add(parent.author.getId());
         visitedUuids.add(current.parentUuid);
         current = parent;
@@ -307,14 +306,13 @@
    * @return an optional comment that will be present if the given comment has a parent, and is
    *     empty if it does not.
    */
-  private Optional<Comment> getParent(Comment child) {
+  private Optional<HumanComment> getParent(Comment child) {
     if (child.parentUuid == null) {
       return Optional.empty();
     }
-
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublished(changeData.notes(), key);
+      return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
     } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
@@ -448,7 +446,7 @@
         // If the comment has a quote, don't bother loading the parent message.
         if (!hasQuote(blocks)) {
           // Set parent comment info.
-          Optional<Comment> parent = getParent(comment);
+          Optional<HumanComment> parent = getParent(comment);
           if (parent.isPresent()) {
             commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
           }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 07b58f2..57f6353 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -82,13 +83,13 @@
     FIXED
   }
 
-  private static Key key(Comment c) {
+  private static Key key(HumanComment c) {
     return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
 
-  private List<Comment> put = new ArrayList<>();
+  private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
   @AssistedInject
@@ -119,7 +120,7 @@
     this.draftsProject = allUsers;
   }
 
-  public void putComment(Comment c) {
+  public void putComment(HumanComment c) {
     checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
@@ -128,7 +129,7 @@
   /**
    * Marks a comment for deletion. Called when the comment is deleted because the user published it.
    */
-  public void markCommentPublished(Comment c) {
+  public void markCommentPublished(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.PUBLISHED);
@@ -137,7 +138,7 @@
   /**
    * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
    */
-  public void deleteComment(Comment c) {
+  public void deleteComment(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.DELETED);
@@ -191,7 +192,7 @@
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
-    for (Comment c : put) {
+    for (HumanComment c : put) {
       if (!delete.keySet().contains(key(c))) {
         cache.get(c.getCommitId()).putComment(c);
       }
@@ -259,7 +260,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.DRAFT);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.DRAFT);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 0a71fa1..cf854c7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -207,9 +208,19 @@
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
         throws IOException {
+      return scan(repo, project, null);
+    }
+
+    public Stream<ChangeNotesResult> scan(
+        Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
+        throws IOException {
       ScanResult sr = scanChangeIds(repo);
 
-      return sr.all().stream().map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
+      Stream<Change.Id> idStream = sr.all().stream();
+      if (changeIdPredicate != null) {
+        idStream = idStream.filter(changeIdPredicate);
+      }
+      return idStream.map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
     }
 
     @Nullable
@@ -425,14 +436,14 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<ObjectId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, HumanComment> getHumanComments() {
     return state.publishedComments();
   }
 
   public ImmutableSet<Comment.Key> getCommentKeys() {
     if (commentKeys == null) {
       ImmutableSet.Builder<Comment.Key> b = ImmutableSet.builder();
-      for (Comment c : getComments().values()) {
+      for (Comment c : getHumanComments().values()) {
         b.add(new Comment.Key(c.key));
       }
       commentKeys = b.build();
@@ -444,11 +455,11 @@
     return state.updateCount();
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(Account.Id author) {
+  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(
+  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(
       Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
@@ -492,7 +503,7 @@
     return robotCommentNotes;
   }
 
-  public boolean containsComment(Comment c) {
+  public boolean containsComment(HumanComment c) {
     if (containsCommentPublished(c)) {
       return true;
     }
@@ -501,7 +512,7 @@
   }
 
   public boolean containsCommentPublished(Comment c) {
-    for (Comment l : getComments().values()) {
+    for (Comment l : getHumanComments().values()) {
       if (c.key.equals(l.key)) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index a884b70..f639f49 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -61,7 +61,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -121,7 +121,7 @@
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final ListMultimap<ObjectId, Comment> comments;
+  private final ListMultimap<ObjectId, HumanComment> humanComments;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -178,7 +178,7 @@
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
-    comments = MultimapBuilder.hashKeys().arrayListValues().build();
+    humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -249,7 +249,7 @@
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
-        comments,
+        humanComments,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
@@ -735,12 +735,12 @@
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
     revisionNoteMap =
         RevisionNoteMap.parse(
-            changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.PUBLISHED);
+            changeNoteJson, reader, NoteMap.read(reader, tipCommit), HumanComment.Status.PUBLISHED);
     Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
     for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
-      for (Comment c : e.getValue().getEntities()) {
-        comments.put(e.getKey(), c);
+      for (HumanComment c : e.getValue().getEntities()) {
+        humanComments.put(e.getKey(), c);
       }
     }
 
@@ -1055,7 +1055,7 @@
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            comments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
+            humanComments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
             approvals.values(), psa -> psa.key().patchSetId(), missing);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 0f27b75..a2ca066 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -123,7 +123,7 @@
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
-      ListMultimap<ObjectId, Comment> publishedComments,
+      ListMultimap<ObjectId, HumanComment> publishedComments,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -314,7 +314,7 @@
 
   abstract ImmutableList<ChangeMessage> changeMessages();
 
-  abstract ImmutableListMultimap<ObjectId, Comment> publishedComments();
+  abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
   abstract int updateCount();
 
@@ -427,7 +427,7 @@
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
 
-    abstract Builder publishedComments(ListMultimap<ObjectId, Comment> publishedComments);
+    abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
     abstract Builder updateCount(int updateCount);
 
@@ -634,8 +634,8 @@
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
-                      .map(r -> GSON.fromJson(r, Comment.class))
-                      .collect(toImmutableListMultimap(Comment::getCommitId, c -> c)))
+                      .map(r -> GSON.fromJson(r, HumanComment.class))
+                      .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
               .updateCount(proto.getUpdateCount());
       return b.build();
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 4e52093..bf2cf07 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -29,13 +29,13 @@
 import org.eclipse.jgit.util.MutableInteger;
 
 /** Implements the parsing of comment data, handling JSON decoding and push certificates. */
-class ChangeRevisionNote extends RevisionNote<Comment> {
+class ChangeRevisionNote extends RevisionNote<HumanComment> {
   private final ChangeNoteJson noteJson;
-  private final Comment.Status status;
+  private final HumanComment.Status status;
   private String pushCert;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
     this.noteJson = noteJson;
     this.status = status;
@@ -47,12 +47,13 @@
   }
 
   @Override
-  protected List<Comment> parse(byte[] raw, int offset) throws IOException, ConfigInvalidException {
+  protected List<HumanComment> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException {
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    RevisionNoteData data = parseJson(noteJson, raw, p.value);
-    if (status == Comment.Status.PUBLISHED) {
+    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
@@ -60,11 +61,11 @@
     return data.comments;
   }
 
-  private RevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 41b1b94..3b905d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -50,6 +50,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
 import com.google.common.collect.TreeBasedTable;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -57,6 +58,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
@@ -65,6 +67,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.assistedinject.Assisted;
@@ -73,6 +76,7 @@
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -80,6 +84,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -118,7 +123,7 @@
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
-  private final List<Comment> comments = new ArrayList<>();
+  private final List<HumanComment> comments = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -129,7 +134,7 @@
   private String submissionId;
   private String topic;
   private String commit;
-  private Set<AttentionSetUpdate> attentionSetUpdates;
+  private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
   private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
@@ -285,10 +290,10 @@
     this.psDescription = psDescription;
   }
 
-  public void putComment(Comment.Status status, Comment c) {
+  public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
-    if (status == Comment.Status.DRAFT) {
+    if (status == HumanComment.Status.DRAFT) {
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
@@ -302,7 +307,7 @@
     robotCommentUpdate.putComment(c);
   }
 
-  public void deleteComment(Comment c) {
+  public void deleteComment(HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull().deleteComment(c);
   }
@@ -372,17 +377,39 @@
 
   /**
    * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user.
+   * not be multiple updates for a single user. Only the first update takes place because of the
+   * different priorities: e.g, if we want to add someone to the attention set but also want to
+   * remove someone from the attention set, we should ensure to add/remove that user based on the
+   * priority of the addition and removal. If most importantly we want to remove the user, then we
+   * must first create the removal, and the addition will not take effect.
    */
-  public void setAttentionSetUpdates(Set<AttentionSetUpdate> attentionSetUpdates) {
+  public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
+    if (updates == null || updates.isEmpty()) {
+      return;
+    }
     checkArgument(
-        attentionSetUpdates.stream().noneMatch(a -> a.timestamp() != null),
+        updates.stream().noneMatch(a -> a.timestamp() != null),
         "must not specify timestamp for write");
+
     checkArgument(
-        attentionSetUpdates.stream().map(AttentionSetUpdate::account).distinct().count()
-            == attentionSetUpdates.size(),
+        updates.stream().map(AttentionSetUpdate::account).distinct().count() == updates.size(),
         "must not specify multiple updates for single user");
-    this.attentionSetUpdates = attentionSetUpdates;
+
+    if (plannedAttentionSetUpdates == null) {
+      plannedAttentionSetUpdates = new HashMap<>();
+    }
+
+    Set<Account.Id> currentAccountUpdates =
+        plannedAttentionSetUpdates.values().stream()
+            .map(AttentionSetUpdate::account)
+            .collect(Collectors.toSet());
+    updates.stream()
+        .filter(u -> !currentAccountUpdates.contains(u.account()))
+        .forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u));
+  }
+
+  public void addToPlannedAttentionSetUpdates(AttentionSetUpdate update) {
+    addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
   public void setAssignee(Account.Id assignee) {
@@ -449,7 +476,7 @@
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-    for (Comment c : comments) {
+    for (HumanComment c : comments) {
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
@@ -486,7 +513,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.PUBLISHED);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.PUBLISHED);
   }
 
   private void checkComments(
@@ -583,6 +610,12 @@
 
     if (status != null) {
       addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+      if (status.equals(Change.Status.ABANDONED)) {
+        clearAttentionSet("Change was abandoned");
+      }
+      if (status.equals(Change.Status.MERGED)) {
+        clearAttentionSet("Change was submitted");
+      }
     }
 
     if (topic != null) {
@@ -593,12 +626,6 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
-    if (attentionSetUpdates != null) {
-      for (AttentionSetUpdate attentionSetUpdate : attentionSetUpdates) {
-        addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
-      }
-    }
-
     if (assignee != null) {
       if (assignee.isPresent()) {
         addFooter(msg, FOOTER_ASSIGNEE);
@@ -625,7 +652,7 @@
       addFooter(msg, e.getValue().getFooterKey());
       noteUtil.appendAccountIdIdentString(msg, e.getKey()).append('\n');
     }
-
+    applyReviewerUpdatesToAttentionSet();
     for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
@@ -686,6 +713,11 @@
 
     if (workInProgress != null) {
       addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+      if (workInProgress) {
+        clearAttentionSet("Change was marked work in progress");
+      } else {
+        addAllReviewersToAttentionSet();
+      }
     }
 
     if (revertOf != null) {
@@ -696,6 +728,10 @@
       addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf);
     }
 
+    if (plannedAttentionSetUpdates != null) {
+      updateAttentionSet(msg);
+    }
+
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
     try {
@@ -709,6 +745,68 @@
     return cb;
   }
 
+  private void clearAttentionSet(String reason) {
+    if (getNotes().getAttentionSet() == null) {
+      return;
+    }
+    AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
+        .map(
+            a ->
+                AttentionSetUpdate.createForWrite(
+                    a.account(), AttentionSetUpdate.Operation.REMOVE, reason))
+        .forEach(this::addToPlannedAttentionSetUpdates);
+  }
+
+  private void applyReviewerUpdatesToAttentionSet() {
+    if ((workInProgress != null && workInProgress == true)
+        || getNotes().getChange().isWorkInProgress()) {
+      // Users shouldn't be added to the attention set if the change is work in progress.
+      return;
+    }
+    Set<Account.Id> currentReviewers =
+        getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+    for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
+      // Only add new reviewers to the attention set.
+      if (reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+          && !currentReviewers.contains(reviewer.getKey())) {
+        updates.add(
+            AttentionSetUpdate.createForWrite(
+                reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
+      }
+      // Treat both REMOVED and CC as "removed reviewers".
+      if (!reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+          && currentReviewers.contains(reviewer.getKey())) {
+        updates.add(
+            AttentionSetUpdate.createForWrite(
+                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer was removed"));
+      }
+    }
+    addToPlannedAttentionSetUpdates(updates);
+  }
+
+  private void addAllReviewersToAttentionSet() {
+    getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER).stream()
+        .map(
+            r ->
+                AttentionSetUpdate.createForWrite(
+                    r, AttentionSetUpdate.Operation.ADD, "Change was marked ready for review"))
+        .forEach(this::addToPlannedAttentionSetUpdates);
+  }
+
+  /**
+   * Any updates to the attention set must be done in {@link #addToPlannedAttentionSetUpdates}. This
+   * method is called after all the updates are finished to do the updates once and for real.
+   */
+  private void updateAttentionSet(StringBuilder msg) {
+    if (plannedAttentionSetUpdates == null) {
+      return;
+    }
+    for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
+      addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+    }
+  }
+
   private void addPatchSetFooter(StringBuilder sb, int ps) {
     addFooter(sb, FOOTER_PATCH_SET).append(ps);
     if (psState != null) {
@@ -735,7 +833,7 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && attentionSetUpdates == null
+        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 9c8b369..d0b6247 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -94,14 +93,14 @@
 
     ObjectReader reader = revWalk.getObjectReader();
     RevCommit newTipCommit = revWalk.next(); // The first commit will not be rewritten.
-    Map<String, Comment> parentComments =
+    Map<String, HumanComment> parentComments =
         getPublishedComments(noteUtil, reader, NoteMap.read(reader, newTipCommit));
 
     boolean rewrite = false;
     RevCommit originalCommit;
     while ((originalCommit = revWalk.next()) != null) {
       NoteMap noteMap = NoteMap.read(reader, originalCommit);
-      Map<String, Comment> currComments = getPublishedComments(noteUtil, reader, noteMap);
+      Map<String, HumanComment> currComments = getPublishedComments(noteUtil, reader, noteMap);
 
       if (!rewrite && currComments.containsKey(uuid)) {
         rewrite = true;
@@ -113,8 +112,8 @@
         continue;
       }
 
-      List<Comment> putInComments = getPutInComments(parentComments, currComments);
-      List<Comment> deletedComments = getDeletedComments(parentComments, currComments);
+      List<HumanComment> putInComments = getPutInComments(parentComments, currComments);
+      List<HumanComment> deletedComments = getDeletedComments(parentComments, currComments);
       newTipCommit =
           revWalk.parseCommit(
               rewriteCommit(
@@ -130,16 +129,16 @@
    * the previous commits.
    */
   @VisibleForTesting
-  public static Map<String, Comment> getPublishedComments(
+  public static Map<String, HumanComment> getPublishedComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
-    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, Status.PUBLISHED).revisionNotes
-        .values().stream()
+    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, HumanComment.Status.PUBLISHED)
+        .revisionNotes.values().stream()
         .flatMap(n -> n.getEntities().stream())
         .collect(toMap(c -> c.key.uuid, Function.identity()));
   }
 
-  public static Map<String, Comment> getPublishedComments(
+  public static Map<String, HumanComment> getPublishedComments(
       ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
     return getPublishedComments(noteUtil.getChangeNoteJson(), reader, noteMap);
@@ -152,11 +151,12 @@
    * @param curMap the comment map of the current commit.
    * @return The comments put in by the current commit.
    */
-  private List<Comment> getPutInComments(Map<String, Comment> parMap, Map<String, Comment> curMap) {
-    List<Comment> comments = new ArrayList<>();
+  private List<HumanComment> getPutInComments(
+      Map<String, HumanComment> parMap, Map<String, HumanComment> curMap) {
+    List<HumanComment> comments = new ArrayList<>();
     for (String key : curMap.keySet()) {
       if (!parMap.containsKey(key)) {
-        Comment comment = curMap.get(key);
+        HumanComment comment = curMap.get(key);
         if (key.equals(uuid)) {
           comment.message = newMessage;
         }
@@ -173,8 +173,8 @@
    * @param curMap the comment map of the current commit.
    * @return The comments deleted by the current commit.
    */
-  private List<Comment> getDeletedComments(
-      Map<String, Comment> parMap, Map<String, Comment> curMap) {
+  private List<HumanComment> getDeletedComments(
+      Map<String, HumanComment> parMap, Map<String, HumanComment> curMap) {
     return parMap.entrySet().stream()
         .filter(c -> !curMap.containsKey(c.getKey()))
         .map(Map.Entry::getValue)
@@ -199,22 +199,22 @@
       RevCommit parentCommit,
       ObjectInserter inserter,
       ObjectReader reader,
-      List<Comment> putInComments,
-      List<Comment> deletedComments)
+      List<HumanComment> putInComments,
+      List<HumanComment> deletedComments)
       throws IOException, ConfigInvalidException {
     RevisionNoteMap<ChangeRevisionNote> revNotesMap =
         RevisionNoteMap.parse(
             noteUtil.getChangeNoteJson(),
             reader,
             NoteMap.read(reader, parentCommit),
-            Status.PUBLISHED);
+            HumanComment.Status.PUBLISHED);
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
 
-    for (Comment c : putInComments) {
+    for (HumanComment c : putInComments) {
       cache.get(c.getCommitId()).putComment(c);
     }
 
-    for (Comment c : deletedComments) {
+    for (HumanComment c : deletedComments) {
       cache.get(c.getCommitId()).deleteComment(c.key);
     }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 3966396..9b403e8 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -50,7 +50,7 @@
   private final Account.Id author;
   private final Ref ref;
 
-  private ImmutableListMultimap<ObjectId, Comment> comments;
+  private ImmutableListMultimap<ObjectId, HumanComment> comments;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
@@ -80,12 +80,12 @@
     return author;
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, HumanComment> getComments() {
     return comments;
   }
 
-  public boolean containsComment(Comment c) {
-    for (Comment existing : comments.values()) {
+  public boolean containsComment(HumanComment c) {
+    for (HumanComment existing : comments.values()) {
       if (c.key.equals(existing.key)) {
         return true;
       }
@@ -120,10 +120,13 @@
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
         RevisionNoteMap.parse(
-            args.changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.DRAFT);
-    ListMultimap<ObjectId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+            args.changeNoteJson,
+            reader,
+            NoteMap.read(reader, tipCommit),
+            HumanComment.Status.DRAFT);
+    ListMultimap<ObjectId, HumanComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (Comment c : rn.getEntities()) {
+      for (HumanComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
new file mode 100644
index 0000000..e570412
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.entities.HumanComment;
+import java.util.List;
+
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ */
+class HumanCommentsRevisionNoteData {
+  String pushCert;
+  List<HumanComment> comments;
+}
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index c0e09ed..da15b34 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -20,7 +20,8 @@
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for (de)serialization to JSON only.
+ * <p>It is intended for serialization to JSON only. It is used for human comments and robot
+ * comments.
  */
 class RevisionNoteData {
   String pushCert;
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 98c9873..5a0b67b 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,7 +42,7 @@
   }
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, HumanComment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
index fc4c9fd..010206c 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -26,7 +26,11 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 
-/** Like {@link RevisionNote} but for robot comments. */
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for deserialization from JSON only. It is used for robot comments only.
+ */
 public class RobotCommentsRevisionNote extends RevisionNote<RobotComment> {
   private final ChangeNoteJson noteUtil;
 
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index c3d9a1d..be0895b 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -26,10 +26,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -41,6 +43,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
@@ -59,6 +62,7 @@
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
@@ -383,8 +387,20 @@
       Set<ContextAwareEdit> editsDueToRebase)
       throws IOException {
     FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
-    long oldSize = getFileSize(objectReader, diffEntry.getOldMode(), diffEntry.getOldPath(), treeA);
-    long newSize = getFileSize(objectReader, diffEntry.getNewMode(), diffEntry.getNewPath(), treeB);
+    long oldSize =
+        getFileSize(
+            objectReader,
+            diffEntry.getOldId(),
+            diffEntry.getOldMode(),
+            diffEntry.getOldPath(),
+            treeA);
+    long newSize =
+        getFileSize(
+            objectReader,
+            diffEntry.getNewId(),
+            diffEntry.getNewMode(),
+            diffEntry.getNewPath(),
+            treeB);
     Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
     PatchListEntry patchListEntry =
         newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
@@ -417,14 +433,18 @@
     return ComparisonType.againstOtherPatchSet();
   }
 
-  private static long getFileSize(ObjectReader reader, FileMode mode, String path, RevTree t)
+  private static long getFileSize(
+      ObjectReader reader, AbbreviatedObjectId abbreviatedId, FileMode mode, String path, RevTree t)
       throws IOException {
     if (!isBlob(mode)) {
       return 0;
     }
-    try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
-      return tw != null ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize() : 0;
+    ObjectId fileId =
+        toObjectId(reader, abbreviatedId).orElseGet(() -> lookupObjectId(reader, path, t));
+    if (ObjectId.zeroId().equals(fileId)) {
+      return 0;
     }
+    return reader.getObjectSize(fileId, OBJ_BLOB);
   }
 
   private static boolean isBlob(FileMode mode) {
@@ -432,6 +452,37 @@
     return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
   }
 
+  private static Optional<ObjectId> toObjectId(
+      ObjectReader reader, AbbreviatedObjectId abbreviatedId) throws IOException {
+    if (abbreviatedId == null) {
+      // In theory, DiffEntry#getOldId or DiffEntry#getNewId can be null for pure renames or pure
+      // mode changes (e.g. DiffEntry#modify doesn't set the IDs). However, the method we call
+      // for diffs (DiffFormatter#scan) seems to always produce DiffEntries with set IDs, even for
+      // pure renames.
+      return Optional.empty();
+    }
+    if (abbreviatedId.isComplete()) {
+      // With the current JGit version and the method we call for diffs (DiffFormatter#scan), this
+      // is the only code path taken right now.
+      return Optional.ofNullable(abbreviatedId.toObjectId());
+    }
+    Collection<ObjectId> objectIds = reader.resolve(abbreviatedId);
+    // It seems very unlikely that an ObjectId which was just abbreviated by the diff computation
+    // now can't be resolved to exactly one ObjectId. The API allows this possibility, though.
+    return objectIds.size() == 1
+        ? Optional.of(Iterables.getOnlyElement(objectIds))
+        : Optional.empty();
+  }
+
+  private static ObjectId lookupObjectId(ObjectReader reader, String path, RevTree tree) {
+    // This variant is very expensive.
+    try (TreeWalk treeWalk = TreeWalk.forPath(reader, path, tree)) {
+      return treeWalk != null ? treeWalk.getObjectId(0) : ObjectId.zeroId();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
   private FileHeader toFileHeader(
       ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
 
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 29a89d6..30930ec 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -381,13 +381,13 @@
     }
 
     private void loadPublished(String file) {
-      for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
+      for (HumanComment c : commentsUtil.publishedByChangeFile(notes, file)) {
         comments.include(notes.getChangeId(), c);
       }
     }
 
     private void loadDrafts(Account.Id me, String file) {
-      for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
+      for (HumanComment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
         comments.include(notes.getChangeId(), c);
       }
     }
diff --git a/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
index fde61ff..9d93ed2 100644
--- a/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
+++ b/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
@@ -158,7 +158,7 @@
       return;
     }
 
-    if (is("org.apache.sshd.server.Command", clazz)) {
+    if (is("org.apache.sshd.server.command.Command", clazz)) {
       sshGen.export(export, clazz);
     } else if (is("javax.servlet.http.HttpServlet", clazz)) {
       httpGen.export(export, clazz);
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index c032c46..87e1ca9 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -589,7 +589,7 @@
       return false;
     }
 
-    if (is("org.apache.sshd.server.Command", type)) {
+    if (is("org.apache.sshd.server.command.Command", type)) {
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f2254d6..f00df53 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Project;
@@ -34,6 +35,7 @@
 /** Collection of routines to populate {@link ProjectInfo}. */
 @Singleton
 public class ProjectJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AllProjectsName allProjects;
   private final WebLinks webLinks;
@@ -50,7 +52,17 @@
     for (LabelType t : projectState.getLabelTypes().getLabelTypes()) {
       LabelTypeInfo labelInfo = new LabelTypeInfo();
       labelInfo.values =
-          t.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
+          t.getValues().stream()
+              .collect(
+                  toMap(
+                      LabelValue::formatValue,
+                      LabelValue::getText,
+                      (v1, v2) -> {
+                        logger.atSevere().log(
+                            "Duplicate values for project: %s, label: %s found: '%s':'%s'",
+                            projectState.getName(), t.getName(), v1, v2);
+                        return v1;
+                      }));
       labelInfo.defaultValue = t.getDefaultValue();
       info.labels.put(t.getName(), labelInfo);
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 01d2c31..34c96dd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -277,7 +278,7 @@
   private List<PatchSetApproval> currentApprovals;
   private List<String> currentFiles;
   private Optional<DiffSummary> diffSummary;
-  private Collection<Comment> publishedComments;
+  private Collection<HumanComment> publishedComments;
   private Collection<RobotComment> robotComments;
   private CurrentUser visibleTo;
   private List<ChangeMessage> messages;
@@ -760,12 +761,12 @@
     return reviewerUpdates;
   }
 
-  public Collection<Comment> publishedComments() {
+  public Collection<HumanComment> publishedComments() {
     if (publishedComments == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      publishedComments = commentsUtil.publishedByChange(notes());
+      publishedComments = commentsUtil.publishedHumanCommentsByChange(notes());
     }
     return publishedComments;
   }
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index bd7981c..b8cf100 100644
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Objects;
 
@@ -39,7 +39,7 @@
         return true;
       }
     }
-    for (Comment c : cd.publishedComments()) {
+    for (HumanComment c : cd.publishedComments()) {
       if (Objects.equals(c.author.getId(), id)) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 4b505c6..f29c6e6 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -22,7 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -48,7 +48,7 @@
 import com.google.gerrit.server.query.change.HasDraftByPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdate.Factory;
 import com.google.gerrit.server.update.BatchUpdateListener;
@@ -123,7 +123,8 @@
       throw new AuthException("Cannot delete drafts of other user");
     }
 
-    CommentFormatter commentFormatter = commentJsonProvider.get().newCommentFormatter();
+    HumanCommentFormatter humanCommentFormatter =
+        commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = rsrc.getUser().getAccountId();
     Timestamp now = TimeUtil.nowTs();
     Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
@@ -137,7 +138,7 @@
       BatchUpdate update =
           updates.computeIfAbsent(
               cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
-      Op op = new Op(commentFormatter, accountId);
+      Op op = new Op(humanCommentFormatter, accountId);
       update.addOp(cd.getId(), op);
       ops.add(op);
     }
@@ -165,12 +166,12 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final CommentFormatter commentFormatter;
+    private final HumanCommentFormatter humanCommentFormatter;
     private final Account.Id accountId;
     private DeletedDraftCommentInfo result;
 
-    Op(CommentFormatter commentFormatter, Account.Id accountId) {
-      this.commentFormatter = commentFormatter;
+    Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+      this.humanCommentFormatter = humanCommentFormatter;
       this.accountId = accountId;
     }
 
@@ -179,12 +180,12 @@
         throws PatchListNotAvailableException, PermissionBackendException {
       ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
       boolean dirty = false;
-      for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
         PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
         setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
-        commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
-        comments.add(commentFormatter.format(c));
+        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(humanCommentFormatter.format(c));
       }
       if (dirty) {
         result = new DeletedDraftCommentInfo();
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index 5176fe9..a3d6388 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -38,7 +38,7 @@
 @Singleton
 public class AddToAttentionSet
     implements RestCollectionModifyView<
-        ChangeResource, AttentionSetEntryResource, AddToAttentionSetInput> {
+        ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final AddToAttentionSetOp.Factory opFactory;
@@ -60,7 +60,7 @@
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource changeResource, AddToAttentionSetInput input)
+  public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
       throws Exception {
     input.user = Strings.nullToEmpty(input.user).trim();
     if (input.user.isEmpty()) {
@@ -84,7 +84,7 @@
     try (BatchUpdate bu =
         updateFactory.create(
             changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
-      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason);
+      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
       bu.addOp(changeResource.getId(), op);
       bu.execute();
       return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 03898b1..4bb6327 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -63,8 +64,8 @@
     return this;
   }
 
-  public CommentFormatter newCommentFormatter() {
-    return new CommentFormatter();
+  public HumanCommentFormatter newHumanCommentFormatter() {
+    return new HumanCommentFormatter();
   }
 
   public RobotCommentFormatter newRobotCommentFormatter() {
@@ -161,15 +162,15 @@
     }
   }
 
-  public class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+  public class HumanCommentFormatter extends BaseCommentFormatter<HumanComment, CommentInfo> {
     @Override
-    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+    protected CommentInfo toInfo(HumanComment c, AccountLoader loader) {
       CommentInfo ci = new CommentInfo();
       fillCommentInfo(c, ci, loader);
       return ci;
     }
 
-    private CommentFormatter() {}
+    private HumanCommentFormatter() {}
   }
 
   class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
index 078c239..00566f3 100644
--- a/java/com/google/gerrit/server/restapi/change/Comments.java
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -14,28 +14,28 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class Comments implements ChildCollection<RevisionResource, CommentResource> {
-  private final DynamicMap<RestView<CommentResource>> views;
+public class Comments implements ChildCollection<RevisionResource, HumanCommentResource> {
+  private final DynamicMap<RestView<HumanCommentResource>> views;
   private final ListRevisionComments list;
   private final CommentsUtil commentsUtil;
 
   @Inject
   Comments(
-      DynamicMap<RestView<CommentResource>> views,
+      DynamicMap<RestView<HumanCommentResource>> views,
       ListRevisionComments list,
       CommentsUtil commentsUtil) {
     this.views = views;
@@ -44,7 +44,7 @@
   }
 
   @Override
-  public DynamicMap<RestView<CommentResource>> views() {
+  public DynamicMap<RestView<HumanCommentResource>> views() {
     return views;
   }
 
@@ -54,13 +54,14 @@
   }
 
   @Override
-  public CommentResource parse(RevisionResource rev, IdString id) throws ResourceNotFoundException {
+  public HumanCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException {
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
+    for (HumanComment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
-        return new CommentResource(rev, c);
+        return new HumanCommentResource(rev, c);
       }
     }
     throw new ResourceNotFoundException(id);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 42032f7..d99d7014 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -18,7 +18,7 @@
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -89,7 +89,7 @@
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
     }
   }
 
@@ -97,7 +97,7 @@
     private final PatchSet.Id psId;
     private final DraftInput in;
 
-    private Comment comment;
+    private HumanComment comment;
 
     private Op(PatchSet.Id psId, DraftInput in) {
       this.psId = psId;
@@ -115,15 +115,15 @@
       String parentUuid = Url.decode(in.inReplyTo);
 
       comment =
-          commentsUtil.newComment(
+          commentsUtil.newHumanComment(
               ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
       setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
 
-      commentsUtil.putComments(
-          ctx.getUpdate(psId), Comment.Status.DRAFT, Collections.singleton(comment));
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index f915728..8580229 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -26,7 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,7 +45,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteComment implements RestModifyView<CommentResource, DeleteCommentInput> {
+public class DeleteComment implements RestModifyView<HumanCommentResource, DeleteCommentInput> {
 
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
@@ -71,7 +71,7 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(CommentResource rsrc, DeleteCommentInput input)
+  public Response<CommentInfo> apply(HumanCommentResource rsrc, DeleteCommentInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
           UpdateException {
     CurrentUser user = userProvider.get();
@@ -90,15 +90,15 @@
 
     ChangeNotes updatedNotes =
         notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
-    List<Comment> changeComments = commentsUtil.publishedByChange(updatedNotes);
-    Optional<Comment> updatedComment =
+    List<HumanComment> changeComments = commentsUtil.publishedHumanCommentsByChange(updatedNotes);
+    Optional<HumanComment> updatedComment =
         changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
     if (!updatedComment.isPresent()) {
       // This should not happen as this endpoint should not remove the whole comment.
       throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
     }
 
-    return Response.ok(commentJson.get().newCommentFormatter().format(updatedComment.get()));
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(updatedComment.get()));
   }
 
   private static String getCommentNewMessage(String name, String reason) {
@@ -110,10 +110,10 @@
   }
 
   private class DeleteCommentOp implements BatchUpdateOp {
-    private final CommentResource rsrc;
+    private final HumanCommentResource rsrc;
     private final String newMessage;
 
-    DeleteCommentOp(CommentResource rsrc, String newMessage) {
+    DeleteCommentOp(HumanCommentResource rsrc, String newMessage) {
       this.rsrc = rsrc;
       this.newMessage = newMessage;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 89fc3b7..71fd4d2 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -80,7 +81,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceNotFoundException, PatchListNotAvailableException {
-      Optional<Comment> maybeComment =
+      Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
@@ -90,9 +91,9 @@
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      Comment c = maybeComment.get();
+      HumanComment c = maybeComment.get();
       setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
-      commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
+      commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index bab1ac9..ab5b9f4 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -64,7 +64,7 @@
       throws ResourceNotFoundException, AuthException {
     checkIdentifiedUser();
     String uuid = id.get();
-    for (Comment c :
+    for (HumanComment c :
         commentsUtil.draftByPatchSetAuthor(
             rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
       if (uuid.equals(c.key.uuid)) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
index 5103325..24085df 100644
--- a/java/com/google/gerrit/server/restapi/change/GetComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class GetComment implements RestReadView<CommentResource> {
+public class GetComment implements RestReadView<HumanCommentResource> {
 
   private final Provider<CommentJson> commentJson;
 
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(CommentResource rsrc) throws PermissionBackendException {
-    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
+  public Response<CommentInfo> apply(HumanCommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
index 797dc9e..ba07b47 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -35,6 +35,6 @@
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc) throws PermissionBackendException {
-    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index 409b0d5..b842f55 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -64,12 +63,12 @@
     return getAsList(listComments(rsrc), rsrc);
   }
 
-  private Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return commentsUtil.publishedByChange(cd.notes());
+    return commentsUtil.publishedHumanCommentsByChange(cd.notes());
   }
 
-  private ImmutableList<CommentInfo> getAsList(Iterable<Comment> comments, ChangeResource rsrc)
+  private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
     ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -77,8 +76,8 @@
     return commentInfos;
   }
 
-  private Map<String, List<CommentInfo>> getAsMap(Iterable<Comment> comments, ChangeResource rsrc)
-      throws PermissionBackendException {
+  private Map<String, List<CommentInfo>> getAsMap(
+      Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
     Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
@@ -87,7 +86,7 @@
     return commentInfosMap;
   }
 
-  private CommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newCommentFormatter();
+  private CommentJson.HumanCommentFormatter getCommentFormatter() {
+    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 24e1d40..3841dc1 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -23,7 +23,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,7 +46,7 @@
     this.commentsUtil = commentsUtil;
   }
 
-  private Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return commentsUtil.draftByChangeAuthor(cd.notes(), rsrc.getUser().getAccountId());
   }
@@ -68,7 +68,11 @@
     return getCommentFormatter().formatAsList(listComments(rsrc));
   }
 
-  private CommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(false).setFillPatchSet(true).newCommentFormatter();
+  private HumanCommentFormatter getCommentFormatter() {
+    return commentJson
+        .get()
+        .setFillAccounts(false)
+        .setFillPatchSet(true)
+        .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index de05d2a..88309ed 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -35,7 +35,7 @@
   }
 
   @Override
-  protected Iterable<Comment> listComments(RevisionResource rsrc) {
+  protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
     ChangeNotes notes = rsrc.getNotes();
     return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().id());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index 199a752..a5fbd92 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -39,7 +39,7 @@
     this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<Comment> listComments(RevisionResource rsrc) {
+  protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
     return commentsUtil.draftByPatchSetAuthor(
         rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
   }
@@ -55,7 +55,7 @@
         commentJson
             .get()
             .setFillAccounts(includeAuthorInfo())
-            .newCommentFormatter()
+            .newHumanCommentFormatter()
             .format(listComments(rsrc)));
   }
 
@@ -64,7 +64,7 @@
     return commentJson
         .get()
         .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
+        .newHumanCommentFormatter()
         .formatAsList(listComments(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 387d0a8..52a8f47 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -18,10 +18,10 @@
 import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
 import static com.google.gerrit.server.change.ChangeMessageResource.CHANGE_MESSAGE_KIND;
 import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
-import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.FixResource.FIX_KIND;
+import static com.google.gerrit.server.change.HumanCommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index a16d4f9..f57ac9c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -811,8 +812,8 @@
   }
 
   /**
-   * Used to compare existing {@link Comment}-s with {@link CommentInput} comments by copying only
-   * the fields to compare.
+   * Used to compare existing {@link HumanComment}-s with {@link CommentInput} comments by copying
+   * only the fields to compare.
    */
   @AutoValue
   abstract static class CommentSetEntry {
@@ -942,7 +943,7 @@
 
       // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
       // object.
-      Map<String, Comment> drafts = new HashMap<>();
+      Map<String, HumanComment> drafts = new HashMap<>();
       // If there are inputComments we need the deduplication loop below, so we have to read (and
       // publish) drafts here.
       if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
@@ -954,7 +955,7 @@
       }
 
       // This will be populated with Comment-s created from inputComments.
-      List<Comment> toPublish = new ArrayList<>();
+      List<HumanComment> toPublish = new ArrayList<>();
 
       Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
@@ -965,11 +966,11 @@
       for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
         String path = entry.getKey();
         for (CommentInput inputComment : entry.getValue()) {
-          Comment comment = drafts.remove(Url.decode(inputComment.id));
+          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
           if (comment == null) {
             String parent = Url.decode(inputComment.inReplyTo);
             comment =
-                commentsUtil.newComment(
+                commentsUtil.newHumanComment(
                     ctx,
                     path,
                     psId,
@@ -1014,7 +1015,7 @@
           break;
       }
       ChangeUpdate changeUpdate = ctx.getUpdate(psId);
-      commentsUtil.putComments(changeUpdate, Comment.Status.PUBLISHED, toPublish);
+      commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, toPublish);
       comments.addAll(toPublish);
       return !toPublish.isEmpty();
     }
@@ -1134,7 +1135,7 @@
     }
 
     private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedByChange(ctx.getNotes()).stream()
+      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
           .map(CommentSetEntry::create)
           .collect(toSet());
     }
@@ -1145,7 +1146,7 @@
           .collect(toSet());
     }
 
-    private Map<String, Comment> changeDrafts(ChangeContext ctx) {
+    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
       return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
           .collect(
               Collectors.toMap(
@@ -1156,7 +1157,7 @@
                   }));
     }
 
-    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) {
+    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
       return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
           .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index ea58365..f327f16 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -93,7 +94,7 @@
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.ok(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
     }
   }
 
@@ -101,7 +102,7 @@
     private final Comment.Key key;
     private final DraftInput in;
 
-    private Comment comment;
+    private HumanComment comment;
 
     private Op(Comment.Key key, DraftInput in) {
       this.key = key;
@@ -111,15 +112,15 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceNotFoundException, PatchListNotAvailableException {
-      Optional<Comment> maybeComment =
+      Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
         throw new ResourceNotFoundException("comment not found: " + key);
       }
-      Comment origComment = maybeComment.get();
-      comment = new Comment(origComment);
+      HumanComment origComment = maybeComment.get();
+      comment = new HumanComment(origComment);
       // Copy constructor preserved old real author; replace with current real
       // user.
       ctx.getUser().updateRealAccountId(comment::setRealAuthor);
@@ -135,17 +136,19 @@
         // Updating the path alters the primary key, which isn't possible.
         // Delete then recreate the comment instead of an update.
 
-        commentsUtil.deleteComments(update, Collections.singleton(origComment));
+        commentsUtil.deleteHumanComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
       setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
-      commentsUtil.putComments(
-          update, Comment.Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
+      commentsUtil.putHumanComments(
+          update,
+          HumanComment.Status.DRAFT,
+          Collections.singleton(update(comment, in, ctx.getWhen())));
       return true;
     }
   }
 
-  private static Comment update(Comment e, DraftInput in, Timestamp when) {
+  private static HumanComment update(HumanComment e, DraftInput in, Timestamp when) {
     if (in.side != null) {
       e.side = in.side();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index ccf375a..5d6ec4c 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -61,7 +61,7 @@
         updateFactory.create(
             changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
       RemoveFromAttentionSetOp op =
-          opFactory.create(attentionResource.getAccountId(), input.reason);
+          opFactory.create(attentionResource.getAccountId(), input.reason, true);
       bu.addOp(changeResource.getId(), op);
       bu.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 534c164..e77bfe7 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -214,7 +214,7 @@
 
       updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
-        return Response.ok(new Output(change));
+        return Response.ok(new Output(updatedChange));
       }
 
       throw new IllegalStateException(
diff --git a/java/com/google/gerrit/server/rules/PrologOptions.java b/java/com/google/gerrit/server/rules/PrologOptions.java
index da9b3ab..a176f04 100644
--- a/java/com/google/gerrit/server/rules/PrologOptions.java
+++ b/java/com/google/gerrit/server/rules/PrologOptions.java
@@ -15,18 +15,21 @@
 package com.google.gerrit.server.rules;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
 
 @AutoValue
 public abstract class PrologOptions {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static PrologOptions defaultOptions() {
     return new AutoValue_PrologOptions.Builder().logErrors(true).skipFilters(false).build();
   }
 
   public static PrologOptions dryRunOptions(String ruleToTest, boolean skipFilters) {
     return new AutoValue_PrologOptions.Builder()
-        .logErrors(false)
+        .logErrors(logger.atFine().isEnabled())
         .skipFilters(skipFilters)
         .rule(ruleToTest)
         .build();
diff --git a/java/com/google/gerrit/server/submit/CircularPathFinder.java b/java/com/google/gerrit/server/submit/CircularPathFinder.java
new file mode 100644
index 0000000..d1920da
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/CircularPathFinder.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+class CircularPathFinder {
+  private CircularPathFinder() {}
+
+  /**
+   * Prints a circular path according to the nodes in {@code p} and the start node {@code target}.
+   */
+  public static <T> String printCircularPath(Collection<T> p, T target) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(target);
+    ArrayList<T> reverseP = new ArrayList<>(p);
+    Collections.reverse(reverseP);
+    for (T t : reverseP) {
+      sb.append("->");
+      sb.append(t);
+      if (t.equals(target)) {
+        break;
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 9018d9d..f96b0c5 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -635,13 +635,13 @@
         throw e;
       }
 
-      // BatchUpdate may have inadvertently wrapped an IntegrationException
+      // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
       // thrown by some legacy SubmitStrategyOp code that intended the error
       // message to be user-visible. Copy the message from the wrapped
       // exception.
       //
       // If you happen across one of these, the correct fix is to convert the
-      // inner IntegrationException to a ResourceConflictException.
+      // inner IntegrationConflictException to a ResourceConflictException.
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 5558c74..ab28aa9 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -23,7 +23,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -36,13 +35,11 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -394,24 +391,13 @@
     }
   }
 
-  private String getByAccountName() {
-    requireNonNull(submitter, "getByAccountName called before submitter populated");
-    Optional<Account> account =
-        args.accountCache.get(submitter.accountId()).map(AccountState::account);
-    if (account.isPresent() && account.get().fullName() != null) {
-      return " by " + ChangeNoteUtil.getAccountIdAsUsername(account.get().id());
-    }
-    return "";
-  }
-
   private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(
-          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
       return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 6854e90..5adda2c 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,19 +14,14 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -46,17 +41,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -72,10 +61,8 @@
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
 
 public class SubmoduleOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -126,40 +113,15 @@
     }
   }
 
-  private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
-  private final ProjectCache projectCache;
   private final VerboseSuperprojectUpdate verboseSuperProject;
-  private final boolean enableSuperProjectSubscriptions;
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<BranchNameKey, GitModules> branchGitModules;
-
-  /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<BranchNameKey> updatedBranches;
-
-  /**
-   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
-   * which are subscribed to by some superproject.
-   */
-  private final Set<BranchNameKey> affectedBranches;
-
-  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<BranchNameKey> sortedBranches;
-
-  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final SubscriptionGraph subscriptionGraph;
 
   private final BranchTips branchTips = new BranchTips();
-  /**
-   * Multimap of superproject name to all branch names within that superproject which have submodule
-   * subscriptions.
-   */
-  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
-
-  /** All branches subscribed by other projects. */
-  private final Set<BranchNameKey> subscribedBranches;
 
   private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
@@ -169,239 +131,27 @@
       Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleConflictException {
-    this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
-    this.projectCache = projectCache;
     this.verboseSuperProject =
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
-    this.enableSuperProjectSubscriptions =
-        cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
     this.maxCombinedCommitMessageSize =
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
-    this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.affectedBranches = new HashSet<>();
-    this.branchGitModules = new HashMap<>();
-    this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.subscribedBranches = new HashSet<>();
-    this.sortedBranches = calculateSubscriptionMaps();
-  }
-
-  /**
-   * Calculate the internal maps used by the operation.
-   *
-   * <p>In addition to the return value, the following fields are populated as a side effect:
-   *
-   * <ul>
-   *   <li>{@link #affectedBranches}
-   *   <li>{@link #targets}
-   *   <li>{@link #branchesByProject}
-   *   <li>{@link #subscribedBranches}
-   * </ul>
-   *
-   * @return the ordered set to be stored in {@link #sortedBranches}.
-   */
-  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
-  // mutable maps, which makes this whole class difficult to understand.
-  //
-  // A cleaner architecture for this process might be:
-  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
-  //      structure representing the subscription graph, using a separate class with a properly-
-  //      documented interface.
-  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
-  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
-  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
-  //      relevant updates.
-  //
-  // In addition to improving readability, this approach has the advantage of making (1) and (2)
-  // testable using small tests.
-  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps()
-      throws SubmoduleConflictException {
-    if (!enableSuperProjectSubscriptions) {
+    this.subscriptionGraphFactory =
+        new SubscriptionGraph.DefaultFactory(gitmodulesFactory, projectCache, orm);
+    if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+      this.subscriptionGraph = subscriptionGraphFactory.compute(updatedBranches);
+    } else {
       logger.atFine().log("Updating superprojects disabled");
-      return null;
+      this.subscriptionGraph =
+          SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
     }
-
-    logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
-    for (BranchNameKey updatedBranch : updatedBranches) {
-      if (allVisited.contains(updatedBranch)) {
-        continue;
-      }
-
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
-    }
-
-    // Since the searchForSuperprojects will add all branches (related or
-    // unrelated) and ensure the superproject's branches get added first before
-    // a submodule branch. Need remove all unrelated branches and reverse
-    // the order.
-    allVisited.retainAll(affectedBranches);
-    reverse(allVisited);
-    return ImmutableSet.copyOf(allVisited);
-  }
-
-  private void searchForSuperprojects(
-      BranchNameKey current,
-      LinkedHashSet<BranchNameKey> currentVisited,
-      LinkedHashSet<BranchNameKey> allVisited)
-      throws SubmoduleConflictException {
-    logger.atFine().log("Now processing %s", current);
-
-    if (currentVisited.contains(current)) {
-      throw new SubmoduleConflictException(
-          "Branch level circular subscriptions detected:  "
-              + printCircularPath(currentVisited, current));
-    }
-
-    if (allVisited.contains(current)) {
-      return;
-    }
-
-    currentVisited.add(current);
-    try {
-      Collection<SubmoduleSubscription> subscriptions =
-          superProjectSubscriptionsForSubmoduleBranch(current);
-      for (SubmoduleSubscription sub : subscriptions) {
-        BranchNameKey superBranch = sub.getSuperProject();
-        searchForSuperprojects(superBranch, currentVisited, allVisited);
-        targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.project(), superBranch);
-        affectedBranches.add(superBranch);
-        affectedBranches.add(sub.getSubmodule());
-        subscribedBranches.add(sub.getSubmodule());
-      }
-    } catch (IOException e) {
-      throw new StorageException("Cannot find superprojects for " + current, e);
-    }
-    currentVisited.remove(current);
-    allVisited.add(current);
-  }
-
-  private static <T> void reverse(LinkedHashSet<T> set) {
-    if (set == null) {
-      return;
-    }
-
-    Deque<T> q = new ArrayDeque<>(set);
-    set.clear();
-
-    while (!q.isEmpty()) {
-      set.add(q.removeLast());
-    }
-  }
-
-  private <T> String printCircularPath(LinkedHashSet<T> p, T target) {
-    StringBuilder sb = new StringBuilder();
-    sb.append(target);
-    ArrayList<T> reverseP = new ArrayList<>(p);
-    Collections.reverse(reverseP);
-    for (T t : reverseP) {
-      sb.append("->");
-      sb.append(t);
-      if (t.equals(target)) {
-        break;
-      }
-    }
-    return sb.toString();
-  }
-
-  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
-      throws IOException {
-    Collection<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", s);
-    for (RefSpec r : s.getMatchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      if (r.isWildcard()) {
-        // refs/heads/*[:refs/somewhere/*]
-        ret.add(
-            BranchNameKey.create(
-                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
-      } else {
-        // e.g. refs/heads/master[:refs/heads/stable]
-        String dest = r.getDestination();
-        if (dest == null) {
-          dest = r.getSource();
-        }
-        ret.add(BranchNameKey.create(s.getProject(), dest));
-      }
-    }
-
-    for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      OpenRepo or;
-      try {
-        or = orm.getRepo(s.getProject());
-      } catch (NoSuchProjectException e) {
-        // A project listed a non existent project to be allowed
-        // to subscribe to it. Allow this for now, i.e. no exception is
-        // thrown.
-        continue;
-      }
-
-      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
-        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
-          continue;
-        }
-        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
-        if (!ret.contains(b)) {
-          ret.add(b);
-        }
-      }
-    }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
-    return ret;
-  }
-
-  private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      BranchNameKey srcBranch) throws IOException {
-    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
-    Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.project();
-    for (SubscribeSection s :
-        projectCache
-            .get(srcProject)
-            .orElseThrow(illegalState(srcProject))
-            .getSubscribeSections(srcBranch)) {
-      logger.atFine().log("Checking subscribe section %s", s);
-      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
-      for (BranchNameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.project();
-        try {
-          OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.branch());
-          if (id == null) {
-            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
-            continue;
-          }
-        } catch (NoSuchProjectException e) {
-          logger.atFine().log("The project %s doesn't exist", targetProject);
-          continue;
-        }
-
-        GitModules m = branchGitModules.get(targetBranch);
-        if (m == null) {
-          m = gitmodulesFactory.create(targetBranch, orm);
-          branchGitModules.put(targetBranch, m);
-        }
-        ret.addAll(m.subscribedTo(srcBranch));
-      }
-    }
-    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
-    return ret;
   }
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public boolean hasSuperproject(BranchNameKey branch) {
-    return subscribedBranches.contains(branch);
+    return subscriptionGraph.hasSuperproject(branch);
   }
 
   public void updateSuperProjects() throws RestApiException {
@@ -414,11 +164,11 @@
     try {
       for (Project.NameKey project : projects) {
         // only need superprojects
-        if (branchesByProject.containsKey(project)) {
+        if (subscriptionGraph.isAffectedSuperProject(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (BranchNameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
@@ -454,7 +204,7 @@
     int count = 0;
 
     List<SubmoduleSubscription> subscriptions =
-        targets.get(subscriber).stream()
+        subscriptionGraph.getSubscriptions(subscriber).stream()
             .sorted(comparing(SubmoduleSubscription::getPath))
             .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
@@ -507,7 +257,7 @@
     StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
-    for (SubmoduleSubscription s : targets.get(subscriber)) {
+    for (SubmoduleSubscription s : subscriptionGraph.getSubscriptions(subscriber)) {
       updateSubmodule(dc, ed, msgbuf, s);
     }
     ed.finish();
@@ -584,6 +334,7 @@
     }
 
     CodeReviewCommit newCommit = maybeNewCommit.get();
+
     if (Objects.equals(newCommit, oldCommit)) {
       // gitlink have already been updated for this submodule
       return null;
@@ -669,11 +420,11 @@
 
   ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    for (Project.NameKey project : branchesByProject.keySet()) {
+    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
-    for (BranchNameKey branch : updatedBranches) {
+    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
       projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
@@ -686,7 +437,8 @@
       throws SubmoduleConflictException {
     if (current.contains(project)) {
       throw new SubmoduleConflictException(
-          "Project level circular subscriptions detected:  " + printCircularPath(current, project));
+          "Project level circular subscriptions detected:  "
+              + CircularPathFinder.printCircularPath(current, project));
     }
 
     if (projects.contains(project)) {
@@ -695,8 +447,8 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (BranchNameKey branch : branchesByProject.get(project)) {
-      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
+    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
       for (SubmoduleSubscription s : subscriptions) {
         subprojects.add(s.getSubmodule().project());
       }
@@ -712,15 +464,13 @@
 
   ImmutableSet<BranchNameKey> getBranchesInOrder() {
     LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
-    if (sortedBranches != null) {
-      branches.addAll(sortedBranches);
-    }
-    branches.addAll(updatedBranches);
+    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+    branches.addAll(subscriptionGraph.getUpdatedBranches());
     return ImmutableSet.copyOf(branches);
   }
 
   boolean hasSubscription(BranchNameKey branch) {
-    return targets.containsKey(branch);
+    return subscriptionGraph.hasSubscription(branch);
   }
 
   void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
new file mode 100644
index 0000000..406d878
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -0,0 +1,375 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefSpec;
+
+/**
+ * A container which stores subscription relationship. A SubscriptionGraph is calculated every time
+ * changes are pushed. Some branches are updated in these changes, and if these branches are
+ * subscribed by other projects, SubscriptionGraph would record information about these updated
+ * branches and branches/projects affected.
+ */
+public class SubscriptionGraph {
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<BranchNameKey> updatedBranches;
+
+  /**
+   * All branches affected, including those in superprojects and submodules, sorted by submodule
+   * traversal order. To support nested subscriptions, GitLink commits need to be updated in order.
+   * The closer to topological "leaf", the earlier a commit should be updated.
+   *
+   * <p>For example, there are three projects, top level project p1 subscribed to p2, p2 subscribed
+   * to bottom level project p3. When submit a change for p3. We need update both p2 and p1. To be
+   * more precise, we need update p2 first and then update p1.
+   */
+  private final ImmutableSet<BranchNameKey> sortedBranches;
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
+  private final ImmutableSetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
+  private final ImmutableSetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+  /** All branches subscribed by other projects. */
+  private final ImmutableSet<BranchNameKey> subscribedBranches;
+
+  public SubscriptionGraph(
+      Set<BranchNameKey> updatedBranches,
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+      Set<BranchNameKey> subscribedBranches,
+      Set<BranchNameKey> sortedBranches) {
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
+    this.targets = ImmutableSetMultimap.copyOf(targets);
+    this.branchesByProject = ImmutableSetMultimap.copyOf(branchesByProject);
+    this.subscribedBranches = ImmutableSet.copyOf(subscribedBranches);
+    this.sortedBranches = ImmutableSet.copyOf(sortedBranches);
+  }
+
+  /** Returns an empty {@code SubscriptionGraph}. */
+  static SubscriptionGraph createEmptyGraph(Set<BranchNameKey> updatedBranches) {
+    return new SubscriptionGraph(
+        updatedBranches,
+        ImmutableSetMultimap.of(),
+        ImmutableSetMultimap.of(),
+        ImmutableSet.of(),
+        ImmutableSet.of());
+  }
+
+  /** Get branches updated as part of the enclosing submit or push batch. */
+  ImmutableSet<BranchNameKey> getUpdatedBranches() {
+    return updatedBranches;
+  }
+
+  /** Get all superprojects affected. */
+  ImmutableSet<Project.NameKey> getAffectedSuperProjects() {
+    return branchesByProject.keySet();
+  }
+
+  /** See if a {@code project} is a superproject affected. */
+  boolean isAffectedSuperProject(Project.NameKey project) {
+    return branchesByProject.containsKey(project);
+  }
+
+  /**
+   * Returns all branches within the superproject {@code project} which have submodule
+   * subscriptions.
+   */
+  ImmutableSet<BranchNameKey> getAffectedSuperBranches(Project.NameKey project) {
+    return branchesByProject.get(project);
+  }
+
+  /**
+   * Get all affected branches, including the submodule branches and superproject branches, sorted
+   * by traversal order.
+   *
+   * @see SubscriptionGraph#sortedBranches
+   */
+  ImmutableSet<BranchNameKey> getSortedSuperprojectAndSubmoduleBranches() {
+    return sortedBranches;
+  }
+
+  /** Check if a {@code branch} is a submodule of a superproject. */
+  boolean hasSuperproject(BranchNameKey branch) {
+    return subscribedBranches.contains(branch);
+  }
+
+  /** See if a {@code branch} is a superproject branch affected. */
+  boolean hasSubscription(BranchNameKey branch) {
+    return targets.containsKey(branch);
+  }
+
+  /** Get all related {@code SubmoduleSubscription}s whose super branch is {@code branch}. */
+  ImmutableSet<SubmoduleSubscription> getSubscriptions(BranchNameKey branch) {
+    return targets.get(branch);
+  }
+
+  public interface Factory {
+    SubscriptionGraph compute(Set<BranchNameKey> updatedBranches) throws SubmoduleConflictException;
+  }
+
+  static class DefaultFactory implements Factory {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+    private final ProjectCache projectCache;
+    private final GitModules.Factory gitmodulesFactory;
+    private final Map<BranchNameKey, GitModules> branchGitModules;
+    private final MergeOpRepoManager orm;
+
+    // Fields required to the constructor of SubscriptionGraph.
+    /** All affected branches, including those in superprojects and submodules. */
+    private final Set<BranchNameKey> affectedBranches;
+
+    /** @see SubscriptionGraph#targets */
+    private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+    /** @see SubscriptionGraph#branchesByProject */
+    private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+    /** @see SubscriptionGraph#subscribedBranches */
+    private final Set<BranchNameKey> subscribedBranches;
+
+    DefaultFactory(
+        GitModules.Factory gitmodulesFactory, ProjectCache projectCache, MergeOpRepoManager orm) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.projectCache = projectCache;
+      this.orm = orm;
+      this.branchGitModules = new HashMap<>();
+
+      this.affectedBranches = new HashSet<>();
+      this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
+      this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
+      this.subscribedBranches = new HashSet<>();
+    }
+
+    @Override
+    public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches)
+        throws SubmoduleConflictException {
+      return new SubscriptionGraph(
+          updatedBranches,
+          targets,
+          branchesByProject,
+          subscribedBranches,
+          calculateSubscriptionMaps(updatedBranches));
+    }
+
+    /**
+     * Calculate the internal maps used by the operation.
+     *
+     * <p>In addition to the return value, the following fields are populated as a side effect:
+     *
+     * <ul>
+     *   <li>{@link #affectedBranches}
+     *   <li>{@link #targets}
+     *   <li>{@link #branchesByProject}
+     *   <li>{@link #subscribedBranches}
+     * </ul>
+     *
+     * @return the ordered set to be stored in {@link #sortedBranches}.
+     */
+    private Set<BranchNameKey> calculateSubscriptionMaps(Set<BranchNameKey> updatedBranches)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Calculating superprojects - submodules map");
+      LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+      for (BranchNameKey updatedBranch : updatedBranches) {
+        if (allVisited.contains(updatedBranch)) {
+          continue;
+        }
+
+        searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
+      }
+
+      // Since the searchForSuperprojects will add all branches (related or
+      // unrelated) and ensure the superproject's branches get added first before
+      // a submodule branch. Need remove all unrelated branches and reverse
+      // the order.
+      allVisited.retainAll(affectedBranches);
+      reverse(allVisited);
+      return allVisited;
+    }
+
+    private void searchForSuperprojects(
+        BranchNameKey current,
+        LinkedHashSet<BranchNameKey> currentVisited,
+        LinkedHashSet<BranchNameKey> allVisited)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Now processing %s", current);
+
+      if (currentVisited.contains(current)) {
+        throw new SubmoduleConflictException(
+            "Branch level circular subscriptions detected:  "
+                + CircularPathFinder.printCircularPath(currentVisited, current));
+      }
+
+      if (allVisited.contains(current)) {
+        return;
+      }
+
+      currentVisited.add(current);
+      try {
+        Collection<SubmoduleSubscription> subscriptions =
+            superProjectSubscriptionsForSubmoduleBranch(current);
+        for (SubmoduleSubscription sub : subscriptions) {
+          BranchNameKey superBranch = sub.getSuperProject();
+          searchForSuperprojects(superBranch, currentVisited, allVisited);
+          targets.put(superBranch, sub);
+          branchesByProject.put(superBranch.project(), superBranch);
+          affectedBranches.add(superBranch);
+          affectedBranches.add(sub.getSubmodule());
+          subscribedBranches.add(sub.getSubmodule());
+        }
+      } catch (IOException e) {
+        throw new StorageException("Cannot find superprojects for " + current, e);
+      }
+      currentVisited.remove(current);
+      allVisited.add(current);
+    }
+
+    private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
+        throws IOException {
+      Collection<BranchNameKey> ret = new HashSet<>();
+      logger.atFine().log("Inspecting SubscribeSection %s", s);
+      for (RefSpec r : s.getMatchingRefSpecs()) {
+        logger.atFine().log("Inspecting [matching] ref %s", r);
+        if (!r.matchSource(src.branch())) {
+          continue;
+        }
+        if (r.isWildcard()) {
+          // refs/heads/*[:refs/somewhere/*]
+          ret.add(
+              BranchNameKey.create(
+                  s.getProject(), r.expandFromSource(src.branch()).getDestination()));
+        } else {
+          // e.g. refs/heads/master[:refs/heads/stable]
+          String dest = r.getDestination();
+          if (dest == null) {
+            dest = r.getSource();
+          }
+          ret.add(BranchNameKey.create(s.getProject(), dest));
+        }
+      }
+
+      for (RefSpec r : s.getMultiMatchRefSpecs()) {
+        logger.atFine().log("Inspecting [all] ref %s", r);
+        if (!r.matchSource(src.branch())) {
+          continue;
+        }
+        OpenRepo or;
+        try {
+          or = orm.getRepo(s.getProject());
+        } catch (NoSuchProjectException e) {
+          // A project listed a non existent project to be allowed
+          // to subscribe to it. Allow this for now, i.e. no exception is
+          // thrown.
+          continue;
+        }
+
+        for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
+          if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+            continue;
+          }
+          BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
+          if (!ret.contains(b)) {
+            ret.add(b);
+          }
+        }
+      }
+      logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
+      return ret;
+    }
+
+    private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+        BranchNameKey srcBranch) throws IOException {
+      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
+      Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      Project.NameKey srcProject = srcBranch.project();
+      for (SubscribeSection s :
+          projectCache
+              .get(srcProject)
+              .orElseThrow(illegalState(srcProject))
+              .getSubscribeSections(srcBranch)) {
+        logger.atFine().log("Checking subscribe section %s", s);
+        Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
+        for (BranchNameKey targetBranch : branches) {
+          Project.NameKey targetProject = targetBranch.project();
+          try {
+            OpenRepo or = orm.getRepo(targetProject);
+            ObjectId id = or.repo.resolve(targetBranch.branch());
+            if (id == null) {
+              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              continue;
+            }
+          } catch (NoSuchProjectException e) {
+            logger.atFine().log("The project %s doesn't exist", targetProject);
+            continue;
+          }
+
+          GitModules m = branchGitModules.get(targetBranch);
+          if (m == null) {
+            m = gitmodulesFactory.create(targetBranch, orm);
+            branchGitModules.put(targetBranch, m);
+          }
+          ret.addAll(m.subscribedTo(srcBranch));
+        }
+      }
+      logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
+      return ret;
+    }
+
+    private static <T> void reverse(LinkedHashSet<T> set) {
+      if (set == null) {
+        return;
+      }
+
+      Deque<T> q = new ArrayDeque<>(set);
+      set.clear();
+
+      while (!q.isEmpty()) {
+        set.add(q.removeLast());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/truth/NullAwareCorrespondence.java b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
new file mode 100644
index 0000000..687ad94
--- /dev/null
+++ b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.truth;
+
+import com.google.common.base.Function;
+import com.google.common.truth.Correspondence;
+import java.util.Optional;
+
+/** Utility class for constructing null aware {@link Correspondence}s. */
+public class NullAwareCorrespondence {
+  /**
+   * Constructs a {@link Correspondence} that compares elements by transforming the actual elements
+   * using the given function and testing for equality with the expected elements.
+   *
+   * <p>If the actual element is null, it will correspond to a null expected element. This is
+   * different to {@link Correspondence#transforming(Function, String)} which would invoke the
+   * function with a {@code null} argument, requiring the function being able to handle {@code
+   * null}.
+   *
+   * @param actualTransform a {@link Function} taking an actual value and returning a new value
+   *     which will be compared with an expected value to determine whether they correspond
+   * @param description should fill the gap in a failure message of the form {@code "not true that
+   *     <some actual element> is an element that <description> <some expected element>"}, e.g.
+   *     {@code "has an ID of"}
+   */
+  public static <A, E> Correspondence<A, E> transforming(
+      Function<A, ? extends E> actualTransform, String description) {
+    return Correspondence.transforming(
+        actualValue -> Optional.ofNullable(actualValue).map(actualTransform).orElse(null),
+        description);
+  }
+
+  /**
+   * Constructs a {@link Correspondence} that compares elements by transforming the actual elements
+   * using the given function and testing for equality with the expected elements.
+   *
+   * <p>If the actual element is null, it will correspond to a null expected element. This is
+   * different to {@link Correspondence#transforming(Function, Function, String)} which would invoke
+   * the function with a {@code null} argument, requiring the function being able to handle {@code
+   * null}.
+   *
+   * <p>If the expected element is null, it will correspond to a new null expected element. This is
+   * different to {@link Correspondence#transforming(Function, Function, String)} which would invoke
+   * the function with a {@code null} argument, requiring the function being able to handle {@code
+   * null}.
+   *
+   * @param actualTransform a {@link Function} taking an actual value and returning a new value
+   *     which will be compared with an expected value to determine whether they correspond
+   * @param expectedTransform a {@link Function} taking an expected value and returning a new value
+   *     which will be compared with a transformed actual value
+   * @param description should fill the gap in a failure message of the form {@code "not true that
+   *     <some actual element> is an element that <description> <some expected element>"}, e.g.
+   *     {@code "has an ID of"}
+   */
+  public static <A, E> Correspondence<A, E> transforming(
+      Function<A, ? extends E> actualTransform,
+      Function<E, ?> expectedTransform,
+      String description) {
+    return Correspondence.transforming(
+        actualValue -> Optional.ofNullable(actualValue).map(actualTransform).orElse(null),
+        expectedValue -> Optional.ofNullable(expectedValue).map(expectedTransform).orElse(null),
+        description);
+  }
+
+  /**
+   * Private constructor to prevent instantiation of this class.
+   *
+   * <p>This class contains only static method and hence never needs to be instantiated.
+   */
+  private NullAwareCorrespondence() {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index f9ba8a2..0cd6182 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -148,6 +148,7 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.jcraft.jsch.KeyPair;
@@ -161,7 +162,6 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -2901,12 +2901,7 @@
   }
 
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
-    return Correspondence.from(
-        (actualGroup, expectedName) -> {
-          String groupName = actualGroup == null ? null : actualGroup.name;
-          return Objects.equals(groupName, expectedName);
-        },
-        "has name");
+    return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
 
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index dcf2afd..8bc9cd1 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -100,6 +100,7 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -111,7 +112,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -1517,12 +1517,8 @@
   }
 
   private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
-    return Correspondence.from(
-        (actualAccount, expectedName) -> {
-          String username = actualAccount == null ? null : actualAccount.username;
-          return Objects.equals(username, expectedName);
-        },
-        "has username");
+    return NullAwareCorrespondence.transforming(
+        accountInfo -> accountInfo.username, "has username");
   }
 
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index cf349ab..7b99a55 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -31,7 +31,7 @@
 
   @ConfigSuite.Config
   public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_7);
+    return getConfig(ElasticVersion.V7_8);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index a3c0295..840853c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -41,7 +41,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
@@ -225,7 +225,8 @@
     assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
-    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(cd.notes()));
+    HumanComment c =
+        Iterables.getOnlyElement(commentsUtil.publishedHumanCommentsByChange(cd.notes()));
     assertThat(c.message).isEqualTo(ci.message);
     assertThat(c.author.getId()).isEqualTo(user.id());
     assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 2901361..75950e2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1344,11 +1344,11 @@
     assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
+      assertThat(last).startsWith("Change has been successfully cherry-picked as");
     } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertThat(last).startsWith("Change has been successfully rebased and submitted as");
     } else {
-      assertThat(last).isEqualTo("Change has been successfully merged by Gerrit User 1000000");
+      assertThat(last).isEqualTo("Change has been successfully merged");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index caa8832..01ed1b9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -16,13 +16,21 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.UnmodifiableIterator;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.time.Duration;
 import java.time.Instant;
@@ -69,7 +77,7 @@
   public void addUser() throws Exception {
     PushOneCommit.Result r = createChange();
     int accountId =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "first"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
     assertThat(accountId).isEqualTo(user.id().get());
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
@@ -78,7 +86,7 @@
 
     // Second add is ignored.
     accountId =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "second"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
     assertThat(accountId).isEqualTo(user.id().get());
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
   }
@@ -88,13 +96,13 @@
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
     int accountId1 =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "user"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
     assertThat(accountId1).isEqualTo(user.id().get());
     fakeClock.advance(Duration.ofSeconds(42));
     Instant timestamp2 = fakeClock.now();
     int accountId2 =
         change(r)
-            .addToAttentionSet(new AddToAttentionSetInput(admin.id().toString(), "admin"))
+            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
             ._accountId;
     assertThat(accountId2).isEqualTo(admin.id().get());
 
@@ -111,7 +119,7 @@
   @Test
   public void removeUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "added"));
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
     fakeClock.advance(Duration.ofSeconds(42));
     change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("removed"));
     AttentionSetUpdate expectedAttentionSetUpdate =
@@ -128,9 +136,272 @@
   }
 
   @Test
+  public void changeMessageWhenAddedAndRemovedExplicitly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    assertThat(Iterables.getLast(r.getChange().messages()).getMessage())
+        .contains("Added to attention set");
+
+    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("foo"));
+    assertThat(Iterables.getLast(r.getChange().messages()).getMessage())
+        .contains("Removed from attention set");
+  }
+
+  @Test
   public void removeUnrelatedUser() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("foo"));
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
+
+  @Test
+  public void abandonRemovesUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
+
+    change(r).abandon();
+
+    UnmodifiableIterator<AttentionSetUpdate> attentionSets =
+        r.getChange().attentionSet().iterator();
+    AttentionSetUpdate userUpdate = attentionSets.next();
+    assertThat(userUpdate.account()).isEqualTo(user.id());
+    assertThat(userUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(userUpdate.reason()).isEqualTo("Change was abandoned");
+
+    AttentionSetUpdate adminUpdate = attentionSets.next();
+    assertThat(adminUpdate.account()).isEqualTo(admin.id());
+    assertThat(adminUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(adminUpdate.reason()).isEqualTo("Change was abandoned");
+  }
+
+  @Test
+  public void workInProgressRemovesUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    change(r).setWorkInProgress();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Change was marked work in progress");
+  }
+
+  @Test
+  public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+
+    // Approval also automatically adds you as a reviewer and therefore to the attention set.
+    // TODO(paiking): This is actually not what we want to happen on reply, as the replying user
+    // should be removed from the attention set.
+    change(r1).current().review(ReviewInput.approve());
+
+    PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
+
+    change(r2).current().review(ReviewInput.approve());
+
+    change(r2).current().submit();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r1.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Change was submitted");
+
+    attentionSet = Iterables.getOnlyElement(r2.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Change was submitted");
+  }
+
+  @Test
+  public void reviewersAddedAndRemovedFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+
+    change(r).reviewer(user.email()).remove();
+
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void noChangeMessagesWhenAddedOrRemovedImplictly() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+    change(r).reviewer(user.email()).remove();
+
+    assertThat(
+            r.getChange().messages().stream()
+                .noneMatch(u -> u.getMessage().contains("Added to attention set")))
+        .isTrue();
+    assertThat(
+            r.getChange().messages().stream()
+                .noneMatch(u -> u.getMessage().contains("Removed from attention set")))
+        .isTrue();
+  }
+
+  @Test
+  public void reviewersAddedAndRemovedByEmailFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.email());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+
+    change(r).reviewer(user.email()).remove();
+
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void reviewersInWorkProgressNotAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void addingReviewerWhileMarkingWorkInprogressDoesntAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().setWorkInProgress(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.REVIEWER;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+
+    change(r).current().review(reviewInput);
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void reviewersAddedAsReviewersAgainAreNotAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+    change(r)
+        .attention(user.id().toString())
+        .remove(
+            new RemoveFromAttentionSetInput("removed and not re-added when re-adding as reviewer"));
+
+    change(r).addReviewer(user.id().toString());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason())
+        .isEqualTo("removed and not re-added when re-adding as reviewer");
+  }
+
+  @Test
+  public void ccsAreIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+
+    change(r).addReviewer(addReviewerInput);
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void ccsConsideredSameAsRemovedForExistingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+    change(r).addReviewer(addReviewerInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void readyForReviewAddsAllReviewersToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    change(r).setReadyForReview();
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Change was marked ready for review");
+  }
+
+  @Test
+  public void readyForReviewWhileRemovingReviewerDoesntAddThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput = new ReviewInput().setReady(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void readyForReviewWhileAddingReviewerAddsThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    ReviewInput reviewInput = new ReviewInput().setReady(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.REVIEWER;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("removed"));
+
+    HashtagsInput hashtagsInput = new HashtagsInput();
+    hashtagsInput.add = ImmutableSet.of("tag");
+    change(r).setHashtags(hashtagsInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("removed");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index e48c9d5..15f1a6a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
@@ -1425,16 +1426,16 @@
         RevCommit commitBefore = beforeDelete.get(i);
         RevCommit commitAfter = afterDelete.get(i);
 
-        Map<String, com.google.gerrit.entities.Comment> commentMapBefore =
+        Map<String, HumanComment> commentMapBefore =
             DeleteCommentRewriter.getPublishedComments(
                 noteUtil, reader, NoteMap.read(reader, commitBefore));
-        Map<String, com.google.gerrit.entities.Comment> commentMapAfter =
+        Map<String, HumanComment> commentMapAfter =
             DeleteCommentRewriter.getPublishedComments(
                 noteUtil, reader, NoteMap.read(reader, commitAfter));
 
         if (commentMapBefore.containsKey(targetCommentUuid)) {
           assertThat(commentMapAfter).containsKey(targetCommentUuid);
-          com.google.gerrit.entities.Comment comment = commentMapAfter.get(targetCommentUuid);
+          HumanComment comment = commentMapAfter.get(targetCommentUuid);
           assertThat(comment.message).isEqualTo(expectedMessage);
           comment.message = commentMapBefore.get(targetCommentUuid).message;
           commentMapAfter.put(targetCommentUuid, comment);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index b544f6e..74dfa04 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -58,7 +58,6 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -636,10 +635,8 @@
 
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
-    return Correspondence.from(
-        (relatedChangeAndCommitInfo, status) ->
-            Objects.equals(relatedChangeAndCommitInfo.status, status),
-        "has status");
+    return Correspondence.transforming(
+        relatedChangeAndCommitInfo -> relatedChangeAndCommitInfo.status, "has status");
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index b539bf8..43a5ceb 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -33,7 +33,7 @@
 
   @ConfigSuite.Config
   public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_7);
+    return getConfig(ElasticVersion.V7_8);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index 96864d9..a003f9d 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
-import java.util.Objects;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -616,28 +616,11 @@
   }
 
   private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
-    return Correspondence.from(
-        (actualAccount, expectedId) -> {
-          Account.Id accountId =
-              Optional.ofNullable(actualAccount)
-                  .map(account -> account._accountId)
-                  .map(Account::id)
-                  .orElse(null);
-          return Objects.equals(accountId, expectedId);
-        },
-        "has ID");
+    return NullAwareCorrespondence.transforming(
+        account -> Account.id(account._accountId), "has ID");
   }
 
   private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
-    return Correspondence.from(
-        (actualGroup, expectedUuid) -> {
-          AccountGroup.UUID groupUuid =
-              Optional.ofNullable(actualGroup)
-                  .map(group -> group.id)
-                  .map(AccountGroup::uuid)
-                  .orElse(null);
-          return Objects.equals(groupUuid, expectedUuid);
-        },
-        "has UUID");
+    return NullAwareCorrespondence.transforming(group -> AccountGroup.uuid(group.id), "has UUID");
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index c20650f..f7a806b 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -60,6 +60,8 @@
         return "blacktop/elasticsearch:7.6.2";
       case V7_7:
         return "blacktop/elasticsearch:7.7.1";
+      case V7_8:
+        return "blacktop/elasticsearch:7.8.0";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index a015103..4826490 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index b1de591..d9a4d2e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -46,7 +46,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
       client = HttpAsyncClients.createDefault();
       client.start();
     }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 2e382d4..0fc96f8 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 87a14da..1e56af9 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index c9a7a46..ac7f33b 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -54,6 +54,9 @@
 
     assertThat(ElasticVersion.forVersion("7.7.0")).isEqualTo(ElasticVersion.V7_7);
     assertThat(ElasticVersion.forVersion("7.7.1")).isEqualTo(ElasticVersion.V7_7);
+
+    assertThat(ElasticVersion.forVersion("7.8.0")).isEqualTo(ElasticVersion.V7_8);
+    assertThat(ElasticVersion.forVersion("7.8.1")).isEqualTo(ElasticVersion.V7_8);
   }
 
   @Test
@@ -81,6 +84,7 @@
     assertThat(ElasticVersion.V7_5.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
     assertThat(ElasticVersion.V7_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
     assertThat(ElasticVersion.V7_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_8.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
   }
 
   @Test
@@ -96,6 +100,7 @@
     assertThat(ElasticVersion.V7_5.isV6OrLater()).isTrue();
     assertThat(ElasticVersion.V7_6.isV6OrLater()).isTrue();
     assertThat(ElasticVersion.V7_7.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_8.isV6OrLater()).isTrue();
   }
 
   @Test
@@ -111,5 +116,6 @@
     assertThat(ElasticVersion.V7_5.isV7OrLater()).isTrue();
     assertThat(ElasticVersion.V7_6.isV7OrLater()).isTrue();
     assertThat(ElasticVersion.V7_7.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_8.isV7OrLater()).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java b/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java
new file mode 100644
index 0000000..2679829
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.AllowRenderInFrameFilter.X_FRAME_OPTIONS_HEADER_NAME;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.gerrit.httpd.AllowRenderInFrameFilter.XFrameOption;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AllowRenderInFrameFilterTest {
+
+  private Config cfg = new Config();
+  @Mock ServletRequest request;
+  @Mock HttpServletResponse response;
+  @Mock FilterChain filterChain;
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalse()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalseAndXFormOptionIsSAMEORIGIN()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalseAndXFormOptionIsALLOW()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.ALLOW);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldRestrictAccessToSAMEORIGINWhenCanRenderInFrameIsTrue()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldSkipHeaderWhenCanRenderInFrameIsTrueAndXFormOptionIsALLOW()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.ALLOW);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, never()).addHeader(eq(X_FRAME_OPTIONS_HEADER_NAME), anyString());
+  }
+
+  @Test
+  public void shouldRestrictAccessToSAMEORIGINWhenCanRenderInFrameIsTrueAndXFormOptionIsSAMEORIGIN()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldIgnoreXFrameOriginCaseSensitivity() throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setString("gerrit", null, "xframeOption", "sameOrigin");
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldThrowExceptionWhenUnknownXFormOptionValue() {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setString("gerrit", null, "xframeOption", "unsupported value");
+
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> new AllowRenderInFrameFilter(cfg));
+    assertThat(e).hasMessageThat().contains("gerrit.xframeOption=unsupported value");
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index f3c8671..2306449 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -37,7 +38,7 @@
   }
 
   protected static void assertInlineComment(
-      String message, MailComment comment, Comment inReplyTo) {
+      String message, MailComment comment, HumanComment inReplyTo) {
     assertThat(comment.fileName).isNull();
     assertThat(comment.message).isEqualTo(message);
     assertThat(comment.inReplyTo.key).isEqualTo(inReplyTo.key);
@@ -51,9 +52,9 @@
     assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
   }
 
-  protected static Comment newComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
+  protected static HumanComment newComment(String uuid, String file, String message, int line) {
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
             new Timestamp(0L),
@@ -65,9 +66,10 @@
     return c;
   }
 
-  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
+  protected static HumanComment newRangeComment(
+      String uuid, String file, String message, int line) {
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
             new Timestamp(0L),
@@ -91,8 +93,8 @@
   }
 
   /** Returns a List of default comments for testing. */
-  protected static List<Comment> defaultComments() {
-    List<Comment> comments = new ArrayList<>();
+  protected static List<HumanComment> defaultComments() {
+    List<HumanComment> comments = new ArrayList<>();
     comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
     comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
     comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
diff --git a/javatests/com/google/gerrit/mail/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
index 345cb05..d661278 100644
--- a/javatests/com/google/gerrit/mail/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.List;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -31,7 +31,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
@@ -52,7 +52,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
@@ -73,7 +73,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -96,7 +96,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -121,7 +121,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -135,7 +135,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).isEmpty();
@@ -148,7 +148,7 @@
         newHtmlBody(
             null, null, null, "Also have a comment here.", "This is a nice file", null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
@@ -164,7 +164,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
diff --git a/javatests/com/google/gerrit/mail/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
index 00d5b41..f1d61792 100644
--- a/javatests/com/google/gerrit/mail/TextParserTest.java
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.List;
 import org.junit.Test;
 
@@ -39,7 +39,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.textContent("Looks good to me\n" + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(1);
@@ -60,7 +60,7 @@
                 null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -83,7 +83,7 @@
                 null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -97,7 +97,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.textContent(newPlaintextBody(null, null, null, null, null, null, null) + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).isEmpty();
@@ -111,7 +111,7 @@
                 null, null, null, "Also have a comment here.", "This is a nice file", null, null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
@@ -134,7 +134,7 @@
                 + quotedFooter)
             .replace("> ", ">> "));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -157,7 +157,7 @@
                 "Comment in reply to file comment")
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 60d9d69..248c7d1 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -63,6 +63,7 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/schema/testing",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/testing:assertable-executor",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index ddcfe0c..3ade4d0 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -188,6 +188,7 @@
     assertThat(h2Cache.getIfPresent("foo")).isEqualTo("reload:foo");
   }
 
+  @SuppressWarnings("unchecked")
   private static void resetLoaderAndAnswerLoadAndRefreshCalls(CacheLoader<String, String> loader)
       throws Exception {
     reset(loader);
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
similarity index 99%
rename from javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
rename to javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
index f4fbc78..46ea8b2 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
@@ -23,7 +23,7 @@
 import java.util.List;
 import org.junit.Test;
 
-public class CommentFormatterTest {
+public class HumanCommentFormatterTest {
   private void assertBlock(
       List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
     CommentFormatter.Block block = list.get(index);
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 7192c55..bf9b187 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -244,7 +245,7 @@
     return label;
   }
 
-  protected Comment newComment(
+  protected HumanComment newComment(
       PatchSet.Id psId,
       String filename,
       String UUID,
@@ -257,8 +258,8 @@
       short side,
       ObjectId commitId,
       boolean unresolved) {
-    Comment c =
-        new Comment(
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(UUID, filename, psId.get()),
             commenter.getAccountId(),
             t,
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index efbaed6..a5e7dce 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -714,8 +715,8 @@
 
   @Test
   public void serializePublishedComments() throws Exception {
-    Comment c1 =
-        new Comment(
+    HumanComment c1 =
+        new HumanComment(
             new Comment.Key("uuid1", "file1", 1),
             Account.id(1001),
             new Timestamp(1212L),
@@ -726,8 +727,8 @@
     c1.setCommitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     String c1Json = Serializer.GSON.toJson(c1);
 
-    Comment c2 =
-        new Comment(
+    HumanComment c2 =
+        new HumanComment(
             new Comment.Key("uuid2", "file2", 2),
             Account.id(1002),
             new Timestamp(3434L),
@@ -798,7 +799,7 @@
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
                     "publishedComments",
-                    new TypeLiteral<ImmutableListMultimap<ObjectId, Comment>>() {}.getType())
+                    new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
                 .put("updateCount", int.class)
                 .build());
   }
@@ -970,7 +971,7 @@
                 "startChar", int.class,
                 "endLine", int.class,
                 "endChar", int.class));
-    assertThatSerializedClass(Comment.class)
+    assertThatSerializedClass(HumanComment.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
                 .put("key", Comment.Key.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 964187c..df5903f 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -43,8 +43,8 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmissionId;
@@ -123,7 +123,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -142,7 +142,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, HumanComment> comments = notes.getHumanComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
@@ -185,7 +185,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     update = newUpdate(c, changeOwner);
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -216,7 +216,7 @@
     assertThat(approval.tag()).hasValue(integrationTag);
     assertThat(approval.value()).isEqualTo(-1);
 
-    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, HumanComment> comments = notes.getHumanComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
@@ -704,7 +704,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -717,12 +717,12 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
     update = newUpdate(c, changeOwner);
     attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -739,23 +739,24 @@
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
-            () -> update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
+            () -> update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
     assertThat(thrown).hasMessageThat().contains("must not specify timestamp for write");
   }
 
   @Test
-  public void addAttentionStatus_rejectMultiplePerUser() throws Exception {
+  public void addAttentionStatus_rejectIfSameUserTwice() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate0 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 0");
     AttentionSetUpdate attentionSetUpdate1 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 1");
+
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
             () ->
-                update.setAttentionSetUpdates(
+                update.addToPlannedAttentionSetUpdates(
                     ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1)));
     assertThat(thrown)
         .hasMessageThat()
@@ -771,7 +772,8 @@
     AttentionSetUpdate attentionSetUpdate1 =
         AttentionSetUpdate.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
 
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
+    update.addToPlannedAttentionSetUpdates(
+        ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1186,7 +1188,7 @@
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -1206,7 +1208,7 @@
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
     assertThat(notes.getApprovals()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
-    assertThat(notes.getComments()).isNotEmpty();
+    assertThat(notes.getHumanComments()).isNotEmpty();
 
     // publish ps2
     update = newUpdate(c, changeOwner);
@@ -1222,7 +1224,7 @@
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
     assertThat(notes.getApprovals()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
   }
 
   @Test
@@ -1279,14 +1281,14 @@
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // comment on ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
     Timestamp ts = TimeUtil.nowTs();
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             psId2,
             "a.txt",
@@ -1307,7 +1309,7 @@
     patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
-    assertThat(notes.getComments()).isNotEmpty();
+    assertThat(notes.getHumanComments()).isNotEmpty();
   }
 
   @Test
@@ -1356,7 +1358,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
-      Comment comment1 =
+      HumanComment comment1 =
           newComment(
               psId,
               "file1",
@@ -1371,7 +1373,7 @@
               ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
               false);
       update1.setPatchSetId(psId);
-      update1.putComment(Comment.Status.PUBLISHED, comment1);
+      update1.putComment(HumanComment.Status.PUBLISHED, comment1);
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
@@ -1570,7 +1572,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1585,11 +1587,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1600,7 +1602,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1615,11 +1617,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1630,7 +1632,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file",
@@ -1645,11 +1647,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1660,7 +1662,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "",
@@ -1675,11 +1677,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1699,7 +1701,7 @@
     Timestamp time = TimeUtil.nowTs();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId1,
             "file1",
@@ -1713,7 +1715,7 @@
             (short) 0,
             commitId,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId1,
             "file1",
@@ -1727,7 +1729,7 @@
             (short) 0,
             commitId,
             false);
-    Comment comment3 =
+    HumanComment comment3 =
         newComment(
             psId2,
             "file1",
@@ -1744,13 +1746,13 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId2);
-    update.putComment(Comment.Status.PUBLISHED, comment3);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment3);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .isEqualTo(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -1770,7 +1772,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file",
@@ -1786,12 +1788,12 @@
             false);
     comment.setRealAuthor(changeOwner.getAccountId());
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1809,7 +1811,7 @@
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1824,12 +1826,12 @@
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .isEqualTo(ImmutableListMultimap.of(comment.getCommitId(), comment));
   }
 
@@ -1847,7 +1849,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment commentForBase =
+    HumanComment commentForBase =
         newComment(
             psId,
             "filename",
@@ -1862,11 +1864,11 @@
             commitId1,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, commentForBase);
+    update.putComment(HumanComment.Status.PUBLISHED, commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment commentForPS =
+    HumanComment commentForPS =
         newComment(
             psId,
             "filename",
@@ -1881,10 +1883,10 @@
             commitId2,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, commentForPS);
+    update.putComment(HumanComment.Status.PUBLISHED, commentForPS);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, commentForBase,
@@ -1905,7 +1907,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp timeForComment1 = TimeUtil.nowTs();
     Timestamp timeForComment2 = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename,
@@ -1920,11 +1922,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename,
@@ -1939,10 +1941,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -1963,7 +1965,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename1,
@@ -1978,11 +1980,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename2,
@@ -1997,10 +1999,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -2021,7 +2023,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2036,7 +2038,7 @@
             commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2044,7 +2046,7 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2059,10 +2061,10 @@
             commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, comment1,
@@ -2081,7 +2083,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2096,22 +2098,22 @@
             commitId,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
@@ -2131,7 +2133,7 @@
     // Write two drafts on the same side of one patch set.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename,
@@ -2145,7 +2147,7 @@
             side,
             commitId,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename,
@@ -2159,8 +2161,8 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2170,18 +2172,18 @@
                 commitId, comment1,
                 commitId, comment2))
         .inOrder();
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish first draft.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
@@ -2201,7 +2203,7 @@
     // Write two drafts, one on each side of the patchset.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    Comment baseComment =
+    HumanComment baseComment =
         newComment(
             psId,
             filename,
@@ -2215,7 +2217,7 @@
             (short) 0,
             commitId1,
             false);
-    Comment psComment =
+    HumanComment psComment =
         newComment(
             psId,
             filename,
@@ -2230,8 +2232,8 @@
             commitId2,
             false);
 
-    update.putComment(Comment.Status.DRAFT, baseComment);
-    update.putComment(Comment.Status.DRAFT, psComment);
+    update.putComment(HumanComment.Status.DRAFT, baseComment);
+    update.putComment(HumanComment.Status.DRAFT, psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2240,19 +2242,19 @@
             ImmutableListMultimap.of(
                 commitId1, baseComment,
                 commitId2, psComment));
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish both comments.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
 
-    update.putComment(Comment.Status.PUBLISHED, baseComment);
-    update.putComment(Comment.Status.PUBLISHED, psComment);
+    update.putComment(HumanComment.Status.PUBLISHED, baseComment);
+    update.putComment(HumanComment.Status.PUBLISHED, psComment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, baseComment,
@@ -2271,7 +2273,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             filename,
@@ -2286,7 +2288,7 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.DRAFT, comment);
+    update.putComment(HumanComment.Status.DRAFT, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2316,7 +2318,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2331,7 +2333,7 @@
             commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2339,7 +2341,7 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2354,7 +2356,7 @@
             commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2384,7 +2386,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment =
+    HumanComment comment =
         newComment(
             ps1,
             filename,
@@ -2398,7 +2400,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
@@ -2417,7 +2419,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment draft =
+    HumanComment draft =
         newComment(
             ps1,
             filename,
@@ -2431,7 +2433,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.DRAFT, draft);
+    update.putComment(HumanComment.Status.DRAFT, draft);
     update.commit();
 
     String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
@@ -2439,7 +2441,7 @@
     assertThat(old).isNotNull();
 
     update = newUpdate(c, otherUser);
-    Comment pub =
+    HumanComment pub =
         newComment(
             ps1,
             filename,
@@ -2453,7 +2455,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.PUBLISHED, pub);
+    update.putComment(HumanComment.Status.PUBLISHED, pub);
     update.commit();
 
     assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
@@ -2469,7 +2471,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "filename",
@@ -2484,10 +2486,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
@@ -2501,7 +2503,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "filename",
@@ -2516,10 +2518,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
@@ -2540,7 +2542,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2554,7 +2556,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2568,23 +2570,23 @@
             side,
             commitId2,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).hasSize(2);
+    assertThat(notes.getHumanComments()).hasSize(2);
   }
 
   @Test
@@ -2598,7 +2600,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             "file1",
@@ -2612,7 +2614,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps1,
             "file2",
@@ -2626,23 +2628,23 @@
             side,
             commitId1,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1))
         .containsExactly(comment1, comment2);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
   }
 
   @Test
@@ -2671,7 +2673,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             "file1",
@@ -2685,7 +2687,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps1,
             "file2",
@@ -2699,8 +2701,8 @@
             side,
             commitId1,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     String refName = refsDraftComments(c.getId(), otherUserId);
@@ -2708,7 +2710,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
     assertThat(exactRefAllUsers(refName)).isNotNull();
     assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
@@ -2730,11 +2732,11 @@
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     // Updating an unrelated comment causes the zombie comment to get fixed up.
@@ -2748,7 +2750,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             c.currentPatchSetId(),
             "filename",
@@ -2762,10 +2764,10 @@
             (short) 1,
             commitId,
             false);
-    update1.putComment(Comment.Status.PUBLISHED, comment1);
+    update1.putComment(HumanComment.Status.PUBLISHED, comment1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             c.currentPatchSetId(),
             "filename",
@@ -2779,7 +2781,7 @@
             (short) 1,
             commitId,
             false);
-    update2.putComment(Comment.Status.PUBLISHED, comment2);
+    update2.putComment(HumanComment.Status.PUBLISHED, comment2);
 
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
@@ -2788,7 +2790,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(commitId);
+    List<HumanComment> comments = notes.getHumanComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -2815,14 +2817,14 @@
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
     int numApprovals = notes.getApprovals().size();
-    int numComments = notes.getComments().size();
+    int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
     update.putApproval("Code-Review", (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Comment comment =
+    HumanComment comment =
         newComment(
             update.getPatchSetId(),
             "filename",
@@ -2836,14 +2838,14 @@
             (short) 1,
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
     assertThat(notes.getApprovals()).hasSize(numApprovals);
-    assertThat(notes.getComments()).hasSize(numComments);
+    assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index c2620dc..2c1348c 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import java.sql.Timestamp;
@@ -151,7 +152,7 @@
   @Test
   public void newAdapterRoundTripOfWholeComment() {
     Comment c =
-        new Comment(
+        new HumanComment(
             new Comment.Key("uuid", "filename", 1),
             Account.id(100),
             NON_DST_TS,
@@ -165,7 +166,7 @@
     String json = gson.toJson(c);
     assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
 
-    Comment result = gson.fromJson(json, Comment.class);
+    Comment result = gson.fromJson(json, HumanComment.class);
     // Round-trip lossily truncates ms, but that's ok.
     assertThat(result.writtenOn).isEqualTo(NON_DST_TS_TRUNC);
     result.writtenOn = NON_DST_TS;
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 6a090c1..8818d81 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -45,7 +45,6 @@
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
-
     RevCommit commit = parseCommit(update.getResult());
     assertBodyEquals(
         "Update patch set 1\n"
@@ -62,7 +61,8 @@
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
+            + "Label: Verified=+1\n"
+            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
@@ -245,7 +245,8 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n"
+            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
         update.getResult());
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index bf49884..041366c 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.lib.ObjectId;
@@ -31,7 +31,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
     update.commit();
 
     assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
@@ -44,13 +44,13 @@
     ChangeUpdate update = newUpdate(c, otherUser);
 
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.DRAFT, comment(c.currentPatchSetId()));
     update.commit();
     assertThat(newNotes(c).getDraftComments(otherUserId)).hasSize(1);
     assertableFanOutExecutor.assertInteractions(0);
 
     update = newUpdate(c, otherUser);
-    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
     update.commit();
 
     assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
@@ -63,7 +63,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.DRAFT, comment(c.currentPatchSetId()));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -80,7 +80,7 @@
     assertableFanOutExecutor.assertInteractions(0);
   }
 
-  private Comment comment(PatchSet.Id psId) {
+  private HumanComment comment(PatchSet.Id psId) {
     return newComment(
         psId,
         "filename",
diff --git a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
new file mode 100644
index 0000000..55c9bc3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.gerrit.extensions.annotations.Listen;
+import com.google.gerrit.sshd.SshCommand;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.jar.Manifest;
+import org.junit.Test;
+
+public class AutoRegisterModulesTest {
+
+  @Test
+  public void shouldRegisterSshCommand() throws InvalidPluginException {
+    ModuleGenerator sshModule = mock(ModuleGenerator.class);
+    PluginGuiceEnvironment env = mock(PluginGuiceEnvironment.class);
+
+    when(env.hasSshModule()).thenReturn(true);
+    when(env.newSshModuleGenerator()).thenReturn(sshModule);
+
+    PluginContentScanner scanner = new TestPluginContextScanner();
+    ClassLoader classLoader = this.getClass().getClassLoader();
+
+    AutoRegisterModules objectUnderTest =
+        new AutoRegisterModules("test_plugin_name", env, scanner, classLoader);
+    objectUnderTest.discover();
+
+    verify(sshModule).setPluginName("test_plugin_name");
+    verify(sshModule).export(any(Export.class), eq(TestSshCommand.class));
+  }
+
+  @Export(value = "test")
+  public static class TestSshCommand extends SshCommand {
+    @Override
+    protected void run() throws UnloggedFailure, Failure, Exception {}
+  }
+
+  private static class TestPluginContextScanner implements PluginContentScanner {
+
+    @Override
+    public Manifest getManifest() throws IOException {
+      return null;
+    }
+
+    @Override
+    public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
+        String pluginName, Iterable<Class<? extends Annotation>> annotations)
+        throws InvalidPluginException {
+      Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> extensions = new HashMap<>();
+      extensions.put(
+          Export.class,
+          Lists.newArrayList(
+              new ExtensionMetaData(
+                  "com.google.gerrit.server.plugins.AutoRegisterModulesTest$TestSshCommand",
+                  "com.google.gerrit.extensions.annotations.Export")));
+      extensions.put(Listen.class, Lists.newArrayList());
+      return extensions;
+    }
+
+    @Override
+    public Optional<PluginEntry> getEntry(String resourcePath) throws IOException {
+      return null;
+    }
+
+    @Override
+    public InputStream getInputStream(PluginEntry entry) throws IOException {
+      return null;
+    }
+
+    @Override
+    public Enumeration<PluginEntry> entries() {
+      return null;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 11e98c6..dbcac0d 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -59,8 +59,8 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -3016,7 +3016,7 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
-    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "some reason");
+    AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
 
     assertQuery("attention:" + user.getUserName().get(), change1);
@@ -3029,11 +3029,11 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
 
-    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "reason 1");
+    AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
     Account.Id user2Id =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    input = new AddToAttentionSetInput(user2Id.toString(), "reason 2");
+    input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
 
     List<ChangeInfo> result = newQuery("attention:" + user2Id.toString()).get();
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
new file mode 100644
index 0000000..dbcc209
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.SubscriptionGraph.DefaultFactory;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public class SubscriptionGraphTest {
+  private static final String TEST_PATH = "test/path";
+  private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
+  private static final Project.NameKey SUB_PROJECT = Project.nameKey("Subproject");
+  private static final BranchNameKey SUPER_BRANCH =
+      BranchNameKey.create(SUPER_PROJECT, "refs/heads/one");
+  private static final BranchNameKey SUB_BRANCH =
+      BranchNameKey.create(SUB_PROJECT, "refs/heads/one");
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
+  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
+  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+
+  @Before
+  public void setUp() throws Exception {
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    GitModules emptyMockGitModules = mock(GitModules.class);
+    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
+    when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
+
+    TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
+    TestRepository<Repository> submoduleProject = createRepo(SUB_PROJECT);
+
+    // Make sure that SUPER_BRANCH and SUB_BRANCH can be subscribed.
+    allowSubscription(SUPER_BRANCH);
+    allowSubscription(SUB_BRANCH);
+
+    setSubscription(SUB_BRANCH, ImmutableList.of(SUPER_BRANCH));
+    setSubscription(SUPER_BRANCH, ImmutableList.of());
+    createBranch(
+        superProject, SUPER_BRANCH, superProject.commit().message("Initial commit").create());
+    createBranch(
+        submoduleProject, SUB_BRANCH, submoduleProject.commit().message("Initial commit").create());
+  }
+
+  @Test
+  public void oneSuperprojectOneSubmodule() throws Exception {
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    SubscriptionGraph subscriptionGraph = factory.compute(ImmutableSet.of(SUB_BRANCH));
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects()).containsExactly(SUPER_PROJECT);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(SUPER_PROJECT))
+        .containsExactly(SUPER_BRANCH);
+    assertThat(subscriptionGraph.getSubscriptions(SUPER_BRANCH))
+        .containsExactly(new SubmoduleSubscription(SUPER_BRANCH, SUB_BRANCH, TEST_PATH));
+    assertThat(subscriptionGraph.hasSuperproject(SUB_BRANCH)).isTrue();
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(SUB_BRANCH, SUPER_BRANCH)
+        .inOrder();
+  }
+
+  @Test
+  public void circularSubscription() throws Exception {
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    setSubscription(SUPER_BRANCH, ImmutableList.of(SUB_BRANCH));
+    SubmoduleConflictException e =
+        assertThrows(
+            SubmoduleConflictException.class, () -> factory.compute(ImmutableSet.of(SUB_BRANCH)));
+
+    String expectedErrorMessage =
+        "Subproject,refs/heads/one->Superproject,refs/heads/one->Subproject,refs/heads/one";
+    assertThat(e).hasMessageThat().contains(expectedErrorMessage);
+  }
+
+  @Test
+  public void multipleSuperprojectsToMultipleSubmodules() throws Exception {
+    // Create superprojects and subprojects.
+    Project.NameKey superProject1 = Project.nameKey("superproject1");
+    Project.NameKey superProject2 = Project.nameKey("superproject2");
+    Project.NameKey subProject1 = Project.nameKey("subproject1");
+    Project.NameKey subProject2 = Project.nameKey("subproject2");
+    TestRepository<Repository> superProjectRepo1 = createRepo(superProject1);
+    TestRepository<Repository> superProjectRepo2 = createRepo(superProject2);
+    TestRepository<Repository> submoduleRepo1 = createRepo(subProject1);
+    TestRepository<Repository> submoduleRepo2 = createRepo(subProject2);
+
+    // Initialize super branches.
+    BranchNameKey superBranch1 = BranchNameKey.create(superProject1, "refs/heads/one");
+    BranchNameKey superBranch2 = BranchNameKey.create(superProject2, "refs/heads/one");
+    createBranch(
+        superProjectRepo1,
+        superBranch1,
+        superProjectRepo1.commit().message("Initial commit").create());
+    createBranch(
+        superProjectRepo2,
+        superBranch2,
+        superProjectRepo2.commit().message("Initial commit").create());
+
+    // Initialize sub branches.
+    BranchNameKey submoduleBranch1 = BranchNameKey.create(subProject1, "refs/heads/one");
+    BranchNameKey submoduleBranch2 = BranchNameKey.create(subProject1, "refs/heads/two");
+    BranchNameKey submoduleBranch3 = BranchNameKey.create(subProject2, "refs/heads/one");
+    createBranch(
+        submoduleRepo1, submoduleBranch1, submoduleRepo1.commit().message("Commit1").create());
+    createBranch(
+        submoduleRepo1, submoduleBranch2, submoduleRepo1.commit().message("Commit2").create());
+    createBranch(
+        submoduleRepo2, submoduleBranch3, submoduleRepo2.commit().message("Commit1").create());
+
+    allowSubscription(submoduleBranch1);
+    allowSubscription(submoduleBranch2);
+    allowSubscription(submoduleBranch3);
+
+    // Initialize subscriptions.
+    setSubscription(submoduleBranch1, ImmutableList.of(superBranch1, superBranch2));
+    setSubscription(submoduleBranch2, ImmutableList.of(superBranch1));
+    setSubscription(submoduleBranch3, ImmutableList.of(superBranch1, superBranch2));
+
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(submoduleBranch1, submoduleBranch2));
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects())
+        .containsExactly(superProject1, superProject2);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject1))
+        .containsExactly(superBranch1);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject2))
+        .containsExactly(superBranch2);
+
+    assertThat(subscriptionGraph.getSubscriptions(superBranch1))
+        .containsExactly(
+            new SubmoduleSubscription(superBranch1, submoduleBranch1, TEST_PATH),
+            new SubmoduleSubscription(superBranch1, submoduleBranch2, TEST_PATH));
+    assertThat(subscriptionGraph.getSubscriptions(superBranch2))
+        .containsExactly(new SubmoduleSubscription(superBranch2, submoduleBranch1, TEST_PATH));
+
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch1)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch2)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch3)).isFalse();
+
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(submoduleBranch2, submoduleBranch1, superBranch2, superBranch1)
+        .inOrder();
+  }
+
+  private TestRepository<Repository> createRepo(Project.NameKey project) throws Exception {
+    Repository repo = repoManager.createRepository(project);
+    return new TestRepository<>(repo);
+  }
+
+  private void createBranch(TestRepository<Repository> repo, BranchNameKey branch, RevCommit commit)
+      throws Exception {
+    repo.update(branch.branch(), commit);
+  }
+
+  private void allowSubscription(BranchNameKey branch) {
+    SubscribeSection s = new SubscribeSection(branch.project());
+    s.addMultiMatchRefSpec("refs/heads/*:refs/heads/*");
+    when(mockProjectState.getSubscribeSections(branch)).thenReturn(ImmutableSet.of(s));
+  }
+
+  private void setSubscription(
+      BranchNameKey submoduleBranch, List<BranchNameKey> superprojectBranches) {
+    List<SubmoduleSubscription> subscriptions =
+        superprojectBranches.stream()
+            .map(
+                (targetBranch) ->
+                    new SubmoduleSubscription(targetBranch, submoduleBranch, TEST_PATH))
+            .collect(Collectors.toList());
+    GitModules mockGitModules = mock(GitModules.class);
+    when(mockGitModules.subscribedTo(submoduleBranch)).thenReturn(subscriptions);
+    when(mockGitModulesFactory.create(submoduleBranch, mergeOpRepoManager))
+        .thenReturn(mockGitModules);
+  }
+}
diff --git a/modules/jgit b/modules/jgit
index 8a44216..8e79d5a 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 8a44216e8bdad1a13ce637b1395467600870849a
+Subproject commit 8e79d5a290843b929f073a536a0d678fc74382ca
diff --git a/plugins/package.json b/plugins/package.json
new file mode 100644
index 0000000..e0227d1
--- /dev/null
+++ b/plugins/package.json
@@ -0,0 +1,8 @@
+{
+    "name": "polygerrit-plugin-dependencies-placeholder",
+    "description": "Gerrit Code Review - Polygerrit plugin dependencies placeholder, expected to be overriden by plugins",
+    "browser": true,
+    "dependencies": {},
+    "license": "Apache-2.0",
+    "private": true
+}
\ No newline at end of file
diff --git a/plugins/replication b/plugins/replication
index 5838489..adad397 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 583848945b150503fb76fb780f53bc5c2b42546c
+Subproject commit adad39744571d94805dbf35e5310398a984325ed
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
new file mode 100644
index 0000000..a63f96e
--- /dev/null
+++ b/plugins/yarn.lock
@@ -0,0 +1,3 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+# This is an empty placeholder
\ No newline at end of file
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
index 94750d8..186f0f4 100644
--- a/polygerrit-ui/Polymer3.md
+++ b/polygerrit-ui/Polymer3.md
@@ -14,6 +14,11 @@
 
 To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
 
+### Plugin dependencies
+
+Since most of Gerrit plugins are treated as sub modules and part of the Gerrit workspace when develop, dependencies of plugins are also defined and installed from Gerrit WORKSPACE, currently most of them are `bower_archives`. When moving to npm, if your plugin requires dependencies, you can have them added to your plugin's `package.json` and then link that file to `plugins/package.json` in gerrit.
+Then use `@plugins_npm//:node_modules` to make sure `rollup_bundle` knows the right place to look for. More examples from `image-diff` plugin, [change 271672](https://gerrit-review.googlesource.com/c/plugins/image-diff/+/271672).
+
 ### Related resources
 
 - [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index e7506e0..fc53dbf 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -206,6 +206,7 @@
         "stub": "readonly",
         "suite": "readonly",
         "suiteSetup": "readonly",
+        "suiteTeardown": "readonly",
         "teardown": "readonly",
         "test": "readonly",
         "fixtureFromElement": "readonly",
@@ -219,7 +220,7 @@
       }
     },
     {
-      "files": ["samples/**/*.js", "**/test/plugin.html"],
+      "files": ["samples/**/*.js"],
       "globals": {
         // Settings for samples. You can add globals here if you want to use it
         "Gerrit": "readonly",
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 096c665..bab00b0 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -18,7 +18,7 @@
         ],
     ),
     outs = ["polygerrit_ui.zip"],
-    entry_point = "elements/gr-app.html",
+    entry_point = "elements/gr-app.js",
 )
 
 filegroup(
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
deleted file mode 100644
index 61d7bac..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ /dev/null
@@ -1,74 +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">
-<meta charset="utf-8">
-<title>base-url-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../test/common-test-setup.js';
-/** @type {string} */
-window.CANONICAL_PATH = '/r';
-</script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {BaseUrlBehavior} from './base-url-behavior.js';
-suite('base-url-behavior tests', () => {
-  let element;
-  // eslint-disable-next-line no-unused-vars
-  let overlay;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [
-        BaseUrlBehavior,
-      ],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    overlay = fixture('within-overlay');
-  });
-
-  test('getBaseUrl', () => {
-    assert.deepEqual(element.getBaseUrl(), '/r');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.js b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.js
new file mode 100644
index 0000000..44173323
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.js
@@ -0,0 +1,51 @@
+/**
+ * @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.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {BaseUrlBehavior} from './base-url-behavior.js';
+
+const basicFixture = fixtureFromElement('test-element');
+
+suite('base-url-behavior tests', () => {
+  let element;
+  let originialCanonicalPath;
+
+  suiteSetup(() => {
+    originialCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = '/r';
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        BaseUrlBehavior,
+      ],
+    });
+  });
+
+  suiteTeardown(() => {
+    window.CANONICAL_PATH = originialCanonicalPath;
+  });
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('getBaseUrl', () => {
+    assert.deepEqual(element.getBaseUrl(), '/r');
+  });
+});
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
index ca31ee8..49f27bc 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
@@ -507,7 +507,8 @@
 
     modifierPressed(e) {
       e = getKeyboardEvent(e);
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
+        !!this._inGoKeyMode();
     },
 
     isModifierPressed(e, modifier) {
@@ -580,11 +581,14 @@
         this._addOwnKeyBindings(key, shortcuts[key]);
       }
 
+      // each component that uses this behaviour must be aware if go key is
+      // pressed or not, since it needs to check it as a modifier
+      this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+      this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+
       // If any of the shortcuts utilized GO_KEY, then they are handled
       // directly by this behavior.
       if (this._shortcut_go_table.size > 0) {
-        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
-        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
         this._shortcut_go_table.forEach((handler, key) => {
           this.addOwnKeyBinding(key, '_handleGoAction');
         });
@@ -611,12 +615,15 @@
     },
 
     _handleGoKeyDown(e) {
-      if (this.modifierPressed(e)) { return; }
       this._shortcut_go_key_last_pressed = Date.now();
     },
 
     _handleGoKeyUp(e) {
-      this._shortcut_go_key_last_pressed = null;
+      // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
+      // so that users can trigger `g + i` by pressing g and i quickly.
+      setTimeout(() => {
+        this._shortcut_go_key_last_pressed = null;
+      }, GO_KEY_TIMEOUT_MS);
     },
 
     _inGoKeyMode() {
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index d25ee76..1aa3e4b 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -314,6 +314,13 @@
 
     return RANGE_NAMES.includes(name.toUpperCase());
   }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapExclusiveToggle(e) {
+    e.preventDefault();
+  }
 }
 
 customElements.define(GrPermission.is, GrPermission);
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
index ef4f1da..ed4f64a 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
@@ -93,6 +93,7 @@
               checked="{{permission.value.exclusive}}"
               on-change="_handleValueChange"
               disabled$="[[!editing]]"
+              on-tap="_onTapExclusiveToggle"
             ></paper-toggle-button
             >Exclusive
           </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
deleted file mode 100644
index d161423..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ /dev/null
@@ -1,52 +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.
- */
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-command_html.js';
-
-/** @extends PolymerElement */
-class GrRepoCommand extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-command'; }
-
-  static get properties() {
-    return {
-      title: String,
-      disabled: Boolean,
-      tooltip: String,
-    };
-  }
-
-  /**
-   * Fired when command button is tapped.
-   *
-   * @event command-tap
-   */
-
-  _onCommandTap() {
-    this.dispatchEvent(
-        new CustomEvent('command-tap', {bubbles: true, composed: true}));
-  }
-}
-
-customElements.define(GrRepoCommand.is, GrRepoCommand);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
deleted file mode 100644
index a73f071..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ /dev/null
@@ -1,53 +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">
-<meta charset="utf-8">
-<title>gr-repo-command</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-command></gr-repo-command>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-command.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-repo-command tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('dispatched command-tap on button tap', done => {
-    element.addEventListener('command-tap', () => {
-      done();
-    });
-    MockInteractions.tap(
-        dom(element.root).querySelector('gr-button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 25f656d..4ab1b98 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -24,7 +24,6 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-create-change-dialog/gr-create-change-dialog.js';
-import '../gr-repo-command/gr-repo-command.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
index a70aa11..fba5e4e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -150,6 +150,13 @@
     this.dispatchEvent(new CustomEvent(
         this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
   }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapPluginBoolean(e) {
+    e.preventDefault();
+  }
 }
 
 customElements.define(GrRepoPluginConfig.is, GrRepoPluginConfig);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
index 80d77d6..ee633463 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
@@ -63,6 +63,7 @@
                 on-change="_handleBooleanChange"
                 data-option-key$="[[option._key]]"
                 disabled$="[[_computeDisabled(option.info.editable)]]"
+                on-tap="_onTapPluginBoolean"
               ></paper-toggle-button>
             </template>
             <template is="dom-if" if="[[_isList(option.info.type)]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index a3fe990..51b4767 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -37,6 +37,7 @@
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GrDisplayNameUtils} from '../../../scripts/gr-display-name-utils/gr-display-name-utils.js';
 import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
@@ -47,6 +48,9 @@
   LARGE: 1000,
 };
 
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
 /**
  * @appliesMixin RESTClientMixin
  * @extends PolymerElement
@@ -73,6 +77,7 @@
 
       /** @type {?} */
       change: Object,
+      config: Object,
       changeURL: {
         type: String,
         computed: '_computeChangeURL(change)',
@@ -207,6 +212,45 @@
     }
   }
 
+  _hasAttention(account) {
+    if (!this.change || !this.change.attention_set) return false;
+    return this.change.attention_set.hasOwnProperty(account._account_id);
+  }
+
+  /**
+   * Computes the array of all reviewers with sorting the reviewers first
+   * that are in the attention set.
+   */
+  _computeReviewers(change) {
+    if (!change || !change.reviewers || !change.reviewers.REVIEWER) return [];
+    const reviewers = [...change.reviewers.REVIEWER];
+    reviewers.sort((r1, r2) => {
+      if (this._hasAttention(r1)) return -1;
+      if (this._hasAttention(r2)) return 1;
+      return 0;
+    });
+    return reviewers;
+  }
+
+  _computePrimaryReviewers(change) {
+    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewers(change) {
+    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewersCount(change) {
+    return this._computeAdditionalReviewers(change).length;
+  }
+
+  _computeAdditionalReviewersTitle(change, config) {
+    if (!change || !config) return '';
+    return this._computeAdditionalReviewers(change)
+        .map(user => GrDisplayNameUtils.getDisplayName(config, user))
+        .join(', ');
+  }
+
   _computeComments(unresolved_comment_count) {
     if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
     return `${unresolved_comment_count} unresolved`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
index 5ff7dde..8c65473 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
@@ -56,6 +56,9 @@
     .reviewers {
       white-space: nowrap;
     }
+    .reviewers {
+      --account-max-length: 75px;
+    }
     .spacer {
       height: 0;
       overflow: hidden;
@@ -156,7 +159,11 @@
     class="cell owner"
     hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
   >
-    <gr-account-link account="[[change.owner]]"></gr-account-link>
+    <gr-account-link
+      highlight-attention
+      change="[[change]]"
+      account="[[change.owner]]"
+    ></gr-account-link>
   </td>
   <td
     class="cell assignee"
@@ -179,16 +186,22 @@
     <div>
       <template
         is="dom-repeat"
-        items="[[change.reviewers.REVIEWER]]"
+        items="[[_computePrimaryReviewers(change)]]"
         as="reviewer"
       >
         <gr-account-link
           hide-avatar=""
           hide-status=""
+          highlight-attention
+          change="[[change]]"
           account="[[reviewer]]"
         ></gr-account-link
-        ><!--
-       --><span class="lastChildHidden">, </span>
+        ><span class="lastChildHidden">, </span>
+      </template>
+      <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
+        <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
+          +[[_computeAdditionalReviewersCount(change, config)]]
+        </span>
       </template>
     </div>
   </td>
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 bd78ae9..baf9920 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
@@ -168,7 +168,6 @@
   /** @override */
   ready() {
     super.ready();
-    this._ensureAttribute('tabindex', 0);
     this.$.restAPI.getConfig().then(config => {
       this._config = config;
     });
@@ -296,6 +295,11 @@
     return idx == selectedIndex;
   }
 
+  _computeTabIndex(sectionIndex, index, selectedIndex) {
+    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+      ? 0 : undefined;
+  }
+
   _computeItemNeedsReview(account, change, showReviewedState) {
     return showReviewedState && !change.reviewed &&
         !change.work_in_progress &&
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
index f7c50e6..4802773 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
@@ -135,10 +135,11 @@
             highlight$="[[_computeItemHighlight(account, change)]]"
             needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
             change="[[change]]"
+            config="[[_config]]"
             visible-change-table-columns="[[visibleChangeTableColumns]]"
             show-number="[[showNumber]]"
             show-star="[[showStar]]"
-            tabindex="0"
+            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
             label-names="[[labelNames]]"
           ></gr-change-list-item>
         </template>
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 1cc2316..c5d50c1 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
@@ -182,10 +182,14 @@
     const {project, dashboard, title, user, sections} = this.params;
     const dashboardPromise = project ?
       this._getProjectDashboard(project, dashboard) :
-      Promise.resolve(GerritNav.getUserDashboard(
-          user,
-          sections,
-          title || this._computeTitle(user)));
+      this.$.restAPI.getConfig().then(
+          config => Promise.resolve(GerritNav.getUserDashboard(
+              user,
+              sections,
+              title || this._computeTitle(user),
+              config
+          ))
+      );
 
     const checkForNewUser = !project && user === 'self';
     return dashboardPromise
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
deleted file mode 100644
index d08f529..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ /dev/null
@@ -1,183 +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">
-<meta charset="utf-8">
-<title>gr-change-metadata</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="element">
-  <template>
-    <gr-change-metadata mutable="true"></gr-change-metadata>
-  </template>
-</test-fixture>
-
-<test-fixture id="plugin-host">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../plugins/gr-plugin-host/gr-plugin-host.js';
-import './gr-change-metadata.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-metadata integration tests', () => {
-  let sandbox;
-  let element;
-
-  const sectionSelectors = [
-    'section.strategy',
-    'section.topic',
-  ];
-
-  const labels = {
-    CI: {
-      all: [
-        {value: 1, name: 'user 2', _account_id: 1},
-        {value: 2, name: 'user '},
-      ],
-      values: {
-        ' 0': 'Don\'t submit as-is',
-        '+1': 'No score',
-        '+2': 'Looks good to me',
-      },
-    },
-  };
-
-  const getStyle = function(selector, name) {
-    return window.getComputedStyle(
-        dom(element.root).querySelector(selector))[name];
-  };
-
-  function createElement() {
-    const element = fixture('element');
-    element.change = {labels, status: 'NEW'};
-    element.revision = {};
-    return element;
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-      deleteVote() { return Promise.resolve({ok: true}); },
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    resetPlugins();
-  });
-
-  suite('by default', () => {
-    setup(done => {
-      element = createElement();
-      flush(done);
-    });
-
-    for (const sectionSelector of sectionSelectors) {
-      test(sectionSelector + ' does not have display: none', () => {
-        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
-      });
-    }
-  });
-
-  suite('with plugin style', () => {
-    setup(done => {
-      resetPlugins();
-      const pluginHost = fixture('plugin-host');
-      pluginHost.config = {
-        plugin: {
-          js_resource_paths: [],
-          html_resource_paths: [
-            new URL('test/plugin.html?' + Math.random(),
-                window.location.href).toString(),
-          ],
-        },
-      };
-      element = createElement();
-      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
-      pluginLoader.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues).then(() => {
-          flush(done);
-        });
-      });
-    });
-
-    for (const sectionSelector of sectionSelectors) {
-      test(sectionSelector + ' may have display: none', () => {
-        assert.equal(getStyle(sectionSelector, 'display'), 'none');
-      });
-    }
-  });
-
-  suite('label updates', () => {
-    let plugin;
-
-    setup(() => {
-      pluginApi.install(p => plugin = p, '0.1',
-          new URL('test/plugin.html?' + Math.random(),
-              window.location.href).toString());
-      sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
-      pluginLoader.loadPlugins([]);
-      element = createElement();
-    });
-
-    test('labels changed callback', done => {
-      let callCount = 0;
-      const labelChangeSpy = sandbox.spy(arg => {
-        callCount++;
-        if (callCount === 1) {
-          assert.deepEqual(arg, labels);
-          assert.equal(arg.CI.all.length, 2);
-          element.set(['change', 'labels'], {
-            CI: {
-              all: [
-                {value: 1, name: 'user 2', _account_id: 1},
-              ],
-              values: {
-                ' 0': 'Don\'t submit as-is',
-                '+1': 'No score',
-                '+2': 'Looks good to me',
-              },
-            },
-          });
-        } else if (callCount === 2) {
-          assert.equal(arg.CI.all.length, 1);
-          done();
-        }
-      });
-
-      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
new file mode 100644
index 0000000..d98bac7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import './gr-change-metadata.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const testHtmlPlugin = document.createElement('dom-module');
+testHtmlPlugin.innerHTML = `
+    <template>
+      <style>
+        html {
+          --change-metadata-assignee: {
+            display: none;
+          }
+          --change-metadata-label-status: {
+            display: none;
+          }
+          --change-metadata-strategy: {
+            display: none;
+          }
+          --change-metadata-topic: {
+            display: none;
+          }
+        }
+      </style>
+    </template>
+  `;
+testHtmlPlugin.register('my-plugin-style');
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-change-metadata mutable="true"></gr-change-metadata>`
+);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata integration tests', () => {
+  let sandbox;
+  let element;
+
+  const sectionSelectors = [
+    'section.strategy',
+    'section.topic',
+  ];
+
+  const labels = {
+    CI: {
+      all: [
+        {value: 1, name: 'user 2', _account_id: 1},
+        {value: 2, name: 'user '},
+      ],
+      values: {
+        ' 0': 'Don\'t submit as-is',
+        '+1': 'No score',
+        '+2': 'Looks good to me',
+      },
+    },
+  };
+
+  const getStyle = function(selector, name) {
+    return window.getComputedStyle(
+        dom(element.root).querySelector(selector))[name];
+  };
+
+  function createElement() {
+    const element = basicFixture.instantiate();
+    element.change = {labels, status: 'NEW'};
+    element.revision = {};
+    return element;
+  }
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+      deleteVote() { return Promise.resolve({ok: true}); },
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  suite('by default', () => {
+    setup(done => {
+      element = createElement();
+      flush(done);
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test(sectionSelector + ' does not have display: none', () => {
+        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('with plugin style', () => {
+    setup(done => {
+      resetPlugins();
+      pluginApi.install(plugin => {
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/style.js');
+      element = createElement();
+      sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
+      pluginLoader.loadPlugins([]);
+      pluginLoader.awaitPluginsLoaded().then(() => {
+        flush(done);
+      });
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test('section.strategy may have display: none', () => {
+        assert.equal(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('label updates', () => {
+    let plugin;
+
+    setup(() => {
+      pluginApi.install(p => {
+        plugin = p;
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/style.js');
+      sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
+      pluginLoader.loadPlugins([]);
+      element = createElement();
+    });
+
+    test('labels changed callback', done => {
+      let callCount = 0;
+      const labelChangeSpy = sandbox.spy(arg => {
+        callCount++;
+        if (callCount === 1) {
+          assert.deepEqual(arg, labels);
+          assert.equal(arg.CI.all.length, 2);
+          element.set(['change', 'labels'], {
+            CI: {
+              all: [
+                {value: 1, name: 'user 2', _account_id: 1},
+              ],
+              values: {
+                ' 0': 'Don\'t submit as-is',
+                '+1': 'No score',
+                '+2': 'Looks good to me',
+              },
+            },
+          });
+        } else if (callCount === 2) {
+          assert.equal(arg.CI.all.length, 1);
+          done();
+        }
+      });
+
+      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
+    });
+  });
+});
\ No newline at end of file
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 9d3b455..b098a93 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
@@ -151,7 +151,7 @@
 
       _currentParents: {
         type: Array,
-        computed: '_computeParents(revision)',
+        computed: '_computeParents(change, revision)',
       },
 
       /** @type {?} */
@@ -493,9 +493,11 @@
     return null;
   }
 
-  _computeParents(revision) {
+  _computeParents(change, revision) {
     if (!revision || !revision.commit) {
-      return undefined;
+      if (!change || !change.current_revision) { return []; }
+      revision = change.revisions[change.current_revision];
+      if (!revision || !revision.commit) { return []; }
     }
     return revision.commit.parents;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
index ca176be..7d84835 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
@@ -108,7 +108,11 @@
     <section>
       <span class="title">Owner</span>
       <span class="value">
-        <gr-account-link account="[[change.owner]]"></gr-account-link>
+        <gr-account-link
+          account="[[change.owner]]"
+          change="[[change]]"
+          highlight-attention
+        ></gr-account-link>
         <template is="dom-if" if="[[_pushCertificateValidation]]">
           <gr-tooltip-content
             has-tooltip=""
@@ -128,6 +132,8 @@
       <span class="value">
         <gr-account-link
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+          change="[[change]]"
+          highlight-attention
         ></gr-account-link>
       </span>
     </section>
@@ -136,6 +142,8 @@
       <span class="value">
         <gr-account-link
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+          change="[[change]]"
+          highlight-attention
         ></gr-account-link>
       </span>
     </section>
@@ -144,6 +152,8 @@
       <span class="value">
         <gr-account-link
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+          change="[[change]]"
+          highlight-attention
         ></gr-account-link>
       </span>
     </section>
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.js
similarity index 90%
rename from polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
rename to polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index 81b9bde..27f9b85 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.js
@@ -1,44 +1,30 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-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">
-<meta charset="utf-8">
-<title>gr-change-metadata</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-metadata></gr-change-metadata>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup-karma.js';
 import '../../core/gr-router/gr-router.js';
 import './gr-change-metadata.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
+const basicFixture = fixtureFromElement('gr-change-metadata');
+
 const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-change-metadata tests', () => {
@@ -47,15 +33,13 @@
 
   setup(() => {
     sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
     stub('gr-rest-api-interface', {
       getConfig() { return Promise.resolve({}); },
       getLoggedIn() { return Promise.resolve(false); },
     });
 
-    element = fixture('basic');
+    element = basicFixture.instantiate();
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
   });
 
   teardown(() => {
@@ -426,20 +410,38 @@
   });
 
   test('_computeParents', () => {
-    const revision = {commit: {parents: [{commit: '123', subject: 'abc'}]}};
-    assert.isUndefined(element._computeParents({}));
-    assert.equal(element._computeParents(revision), revision.commit.parents);
+    const parents = [{commit: '123', subject: 'abc'}];
+    const revision = {commit: {parents}};
+    assert.deepEqual(element._computeParents({}, {}), []);
+    assert.equal(element._computeParents(null, revision), parents);
+    const change = current_revision => {
+      return {current_revision, revisions: {456: revision}};
+    };
+    assert.deepEqual(element._computeParents(change(null), null), []);
+    const change_bad_revision = change('789');
+    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
+    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
+    assert.deepEqual(element._computeParents(change_no_commit, null), []);
+    const change_good = change('456');
+    assert.equal(element._computeParents(change_good, null), parents);
   });
 
   test('_currentParents', () => {
-    element.revision = {
-      commit: {parents: [{commit: '123', subject: 'abc'}]},
+    const revision = parent => {
+      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
     };
-    assert.equal(element._currentParents[0].commit, '123');
-    element.revision = {
-      commit: {parents: [{commit: '12345', subject: 'abc'}]},
+    element.change = {
+      current_revision: '456',
+      revisions: {456: revision('111')},
     };
-    assert.equal(element._currentParents[0].commit, '12345');
+    element.revision = revision('222');
+    assert.equal(element._currentParents[0].commit, '222');
+    element.revision = revision('333');
+    assert.equal(element._currentParents[0].commit, '333');
+    element.revision = null;
+    assert.equal(element._currentParents[0].commit, '111');
+    element.change = {current_revision: null};
+    assert.deepEqual(element._currentParents, []);
   });
 
   test('_computeParentsLabel', () => {
@@ -778,5 +780,4 @@
       });
     });
   });
-});
-</script>
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
deleted file mode 100644
index b3aa98f..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
+++ /dev/null
@@ -1,28 +0,0 @@
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('change-metadata', 'my-plugin-style');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="my-plugin-style">
-  <template>
-    <style>
-      html {
-        --change-metadata-assignee: {
-          display: none;
-        }
-        --change-metadata-label-status: {
-          display: none;
-        }
-        --change-metadata-strategy: {
-          display: none;
-        }
-        --change-metadata-topic: {
-          display: none;
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index faa81b6..acd16d7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -279,9 +279,6 @@
 
   setup(() => {
     sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
     // Since pluginEndpoints are global, must reset state.
     _testOnly_resetEndpoints();
     navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
index df7cb00..a2714d9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
@@ -329,6 +329,12 @@
           as="headerEndpoint"
         >
           <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+            <gr-endpoint-param
+              name="change"
+              value="[[change]]"
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+            </gr-endpoint-param>
           </gr-endpoint-decorator>
         </template>
       </template>
@@ -381,6 +387,8 @@
               as="contentEndpoint"
             >
               <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+                <gr-endpoint-param name="change" value="[[change]]">
+                </gr-endpoint-param>
                 <gr-endpoint-param name="changeNum" value="[[changeNum]]">
                 </gr-endpoint-param>
                 <gr-endpoint-param name="patchRange" value="[[patchRange]]">
@@ -439,7 +447,7 @@
             <div class="comments desktop">
               <span class="drafts"
                 ><!-- This comments ensure that span is empty when the function
-                returns empty string.                
+                returns empty string.
               -->[[_computeDraftsString(changeComments, patchRange,
                 file.__path)]]<!-- This comments ensure that span is empty when
                 the function returns empty string.
@@ -458,7 +466,7 @@
               Without this span, screen readers don't navigate correctly inside
               table, because empty div doesn't rendered. For example, VoiceOver
               jumps back to the whole table.
-              We can use &nbsp instead, but it sounds worse.                            
+              We can use &nbsp instead, but it sounds worse.
               -->
                 No comments
               </span>
@@ -553,6 +561,8 @@
             >
               <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
                 <gr-endpoint-decorator name="[[contentEndpoint]]">
+                  <gr-endpoint-param name="change" value="[[change]]">
+                  </gr-endpoint-param>
                   <gr-endpoint-param name="changeNum" value="[[changeNum]]">
                   </gr-endpoint-param>
                   <gr-endpoint-param name="patchRange" value="[[patchRange]]">
@@ -575,7 +585,7 @@
             >
             <!-- Do not use input type="checkbox" with hidden input and
                   visible label here. Screen readers don't read/interract
-                  correctly with such input. 
+                  correctly with such input.
               -->
             <span
               class="reviewedSwitch"
@@ -611,7 +621,7 @@
           <div class="show-hide" role="gridcell">
             <!-- Do not use input type="checkbox" with hidden input and
                 visible label here. Screen readers don't read/interract
-                correctly with such input. 
+                correctly with such input.
             -->
             <span
               class="show-hide"
@@ -683,6 +693,12 @@
         as="summaryEndpoint"
       >
         <gr-endpoint-decorator name="[[summaryEndpoint]]">
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+          </gr-endpoint-param>
         </gr-endpoint-decorator>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
index ae3a7d2..6e65a19 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
@@ -420,6 +420,13 @@
     }
     return extremes;
   }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapShowAllActivityToggle(e) {
+    e.preventDefault();
+  }
 }
 
 customElements.define(GrMessagesListExperimental.is,
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
index 56cb960..212de59 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
@@ -74,6 +74,7 @@
           checked="{{_showAllActivity}}"
           aria-labelledby="showAllEntriesLabel"
           role="switch"
+          on-tap="_onTapShowAllActivityToggle"
         ></paper-toggle-button>
         <div id="showAllEntriesLabel">
           <span>Show all entries</span>
@@ -84,16 +85,6 @@
         <span class="transparent separator"></span>
       </template>
     </div>
-    <div class="experimentMessage">
-      <iron-icon icon="gr-icons:pets"></iron-icon>
-      <span>You're currently viewing an experimental Change Log view.</span>
-      <a
-        target="_blank"
-        href="https://www.gerritcodereview.com/2020-05-06-change-log-experiment.html"
-      >
-        Learn more
-      </a>
-    </div>
     <gr-button
       id="collapse-messages"
       link=""
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 adf9fd3..1ee9a1e 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
@@ -464,6 +464,13 @@
     }
     return extremes;
   }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapHideAutomated(e) {
+    e.preventDefault();
+  }
 }
 
 customElements.define(GrMessagesList.is, GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
index 5adfc53..2636a54 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
@@ -81,8 +81,9 @@
         checked="{{_hideAutomated}}"
         aria-labelledby="onlyCommentsLabel"
         role="switch"
-      ></paper-toggle-button
-      ><span id="onlyCommentsLabel">Only comments</span>
+        on-tap="_onTapHideAutomated"
+      ></paper-toggle-button>
+      <span id="onlyCommentsLabel">Only comments</span>
       <span class="transparent separator"></span>
     </span>
     <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
deleted file mode 100644
index d3232e9..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ /dev/null
@@ -1,175 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 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">
-<meta charset="utf-8">
-<title>gr-reply-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="plugin-host">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../plugins/gr-plugin-host/gr-plugin-host.js';
-import './gr-reply-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-_testOnly_initGerritPluginApi();
-
-suite('gr-reply-dialog tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  let sandbox;
-
-  const setupElement = element => {
-    element.change = {
-      _number: changeNum,
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          all: [{_account_id: 42, value: 0}],
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    sandbox.stub(element, 'fetchChangeUpdates')
-        .returns(Promise.resolve({isLatest: true}));
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    changeNum = 42;
-    patchNum = 1;
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({_account_id: 42}); },
-    });
-
-    element = fixture('basic');
-    setupElement(element);
-
-    // Allow the elements created by dom-repeat to be stamped.
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_submit blocked when invalid email is supplied to ccs', () => {
-    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sandbox.stub(element, '_purgeReviewersPendingRemove');
-
-    element.$.ccs.$.entry.setText('test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-    flushAsynchronousOperations();
-
-    element.$.ccs.$.entry.setText('test@test.test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  test('lgtm plugin', done => {
-    resetPlugins();
-    const pluginHost = fixture('plugin-host');
-    pluginHost.config = {
-      plugin: {
-        js_resource_paths: [],
-        html_resource_paths: [
-          new URL('test/plugin.html?' + Math.random(),
-              window.location.href).toString(),
-        ],
-      },
-    };
-    element = fixture('basic');
-    setupElement(element);
-    const importSpy =
-        sandbox.spy(element.shadowRoot
-            .querySelector('gr-endpoint-decorator'), '_import');
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      Promise.all(importSpy.returnValues).then(() => {
-        flush(() => {
-          const textarea = element.$.textarea.getNativeTextarea();
-          textarea.value = 'LGTM';
-          textarea.dispatchEvent(new CustomEvent(
-              'input', {bubbles: true, composed: true}));
-          const labelScoreRows = dom(element.$.labelScores.root)
-              .querySelector('gr-label-score-row[name="Code-Review"]');
-          const selectedBtn = dom(labelScoreRows.root)
-              .querySelector('gr-button[data-value="+1"].iron-selected');
-          assert.isOk(selectedBtn);
-          done();
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
new file mode 100644
index 0000000..2a8c418
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-reply-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-reply-dialog tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
+
+  let sandbox;
+
+  const setupElement = element => {
+    element.change = {
+      _number: changeNum,
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          all: [{_account_id: 42, value: 0}],
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    sandbox.stub(element, 'fetchChangeUpdates')
+        .returns(Promise.resolve({isLatest: true}));
+  };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    changeNum = 42;
+    patchNum = 1;
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({_account_id: 42}); },
+    });
+
+    element = basicFixture.instantiate();
+    setupElement(element);
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  test('_submit blocked when invalid email is supplied to ccs', () => {
+    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sandbox.stub(element, '_purgeReviewersPendingRemove');
+
+    element.$.ccs.$.entry.setText('test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+    flushAsynchronousOperations();
+
+    element.$.ccs.$.entry.setText('test@test.test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', done => {
+    resetPlugins();
+    pluginApi.install(plugin => {
+      const replyApi = plugin.changeReply();
+      replyApi.addReplyTextChangedCallback(text => {
+        const label = 'Code-Review';
+        const labelValue = replyApi.getLabelValue(label);
+        if (labelValue &&
+            labelValue === ' 0' &&
+            text.indexOf('LGTM') === 0) {
+          replyApi.setLabelValue(label, '+1');
+        }
+      });
+    }, null, 'http://test.com/lgtm.js');
+    element = basicFixture.instantiate();
+    setupElement(element);
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
+    pluginLoader.loadPlugins([]);
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      flush(() => {
+        const textarea = element.$.textarea.getNativeTextarea();
+        textarea.value = 'LGTM';
+        textarea.dispatchEvent(new CustomEvent(
+            'input', {bubbles: true, composed: true}));
+        const labelScoreRows = dom(element.$.labelScores.root)
+            .querySelector('gr-label-score-row[name="Code-Review"]');
+        const selectedBtn = dom(labelScoreRows.root)
+            .querySelector('gr-button[data-value="+1"].iron-selected');
+        assert.isOk(selectedBtn);
+        done();
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
deleted file mode 100644
index 94787e6..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
+++ /dev/null
@@ -1,33 +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.
--->
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      const replyApi = plugin.changeReply();
-      replyApi.addReplyTextChangedCallback(text => {
-        const label = 'Code-Review';
-        const labelValue = replyApi.getLabelValue(label);
-        if (labelValue &&
-            labelValue === ' 0' &&
-            text.indexOf('LGTM') === 0) {
-          replyApi.setLabelValue(label, '+1');
-        }
-      });
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
index 93926cf..13a41ed 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
@@ -43,7 +43,9 @@
         <gr-account-chip
           class="reviewer"
           account="[[reviewer]]"
+          change="[[change]]"
           on-remove="_handleRemove"
+          highlight-attention
           voteable-text="[[_computeVoteableText(reviewer, change)]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
         >
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index bd91990..28a8d9a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -296,6 +296,13 @@
   _isOnParent(side) {
     return !!side;
   }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapUnresolvedToggle(e) {
+    e.preventDefault();
+  }
 }
 
 customElements.define(GrThreadList.is, GrThreadList);
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
index bb87db8..80983ca 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
@@ -61,14 +61,20 @@
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
       <div class="toggleItem">
-        <paper-toggle-button id="unresolvedToggle" checked="{{_unresolvedOnly}}"
+        <paper-toggle-button
+          id="unresolvedToggle"
+          checked="{{_unresolvedOnly}}"
+          on-tap="_onTapUnresolvedToggle"
           >Only unresolved threads</paper-toggle-button
         >
       </div>
       <div
         class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
       >
-        <paper-toggle-button id="draftToggle" checked="{{_draftsOnly}}"
+        <paper-toggle-button
+          id="draftToggle"
+          checked="{{_draftsOnly}}"
+          on-tap="_onTapUnresolvedToggle"
           >Only threads with drafts</paper-toggle-button
         >
       </div>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 903552a..23a4c55 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -90,7 +90,9 @@
       return undefined;
     }
 
-    const links = [{name: 'Settings', url: '/settings/'}];
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
       const url = this._interpolateUrl(switchAccountUrl, replacements);
@@ -107,6 +109,11 @@
     ];
   }
 
+  _handleShortcutsTap(e) {
+    this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
+        {bubbles: true, composed: true}));
+  }
+
   _handleLocationChange() {
     this._path =
         window.location.pathname +
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
index b47894e..5db7923 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
@@ -37,6 +37,7 @@
     link=""
     items="[[links]]"
     top-content="[[topContent]]"
+    on-tap-item-shortcuts="_handleShortcutsTap"
     horizontal-align="right"
   >
     <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 6c8ed68..a366d09 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -84,12 +84,12 @@
     assert.isUndefined(element._getLinks(null));
 
     // No switch account link.
-    assert.equal(element._getLinks(null, '').length, 2);
+    assert.equal(element._getLinks(null, '').length, 3);
 
     // Unparameterized switch account link.
     let links = element._getLinks('/switch-account', '');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
       name: 'Switch account',
       url: '/switch-account',
       external: true,
@@ -97,8 +97,8 @@
 
     // Parameterized switch account link.
     links = element._getLinks('/switch-account${path}', '/c/123');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
       name: 'Switch account',
       url: '/switch-account/c/123',
       external: true,
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 9f6d050..6cd2491 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -28,7 +28,6 @@
 import {htmlTemplate} from './gr-error-manager_html.js';
 import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {authService} from '../../shared/gr-rest-api-interface/gr-auth.js';
-import {gerritEventEmitter} from '../../shared/gr-event-emitter/gr-event-emitter.js';
 import {appContext} from '../../../services/app-context.js';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
@@ -93,6 +92,7 @@
     this._authErrorHandlerDeregistrationHook;
 
     this.reporting = appContext.reportingService;
+    this.eventEmitter = appContext.eventEmitter;
   }
 
   /** @override */
@@ -106,7 +106,7 @@
     this.listen(document, 'show-auth-required', '_handleAuthRequired');
 
     this._authErrorHandlerDeregistrationHook =
-      gerritEventEmitter.on('auth-error',
+      this.eventEmitter.on('auth-error',
           event => {
             this._handleAuthError(event.message, event.action);
           });
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index 0c0daea..00e781b 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -47,22 +47,6 @@
     return {
       _left: Array,
       _right: Array,
-
-      _propertyBySection: {
-        type: Object,
-        value() {
-          return {
-            [ShortcutSection.EVERYWHERE]: '_everywhere',
-            [ShortcutSection.NAVIGATION]: '_navigation',
-            [ShortcutSection.DASHBOARD]: '_dashboard',
-            [ShortcutSection.CHANGE_LIST]: '_changeList',
-            [ShortcutSection.ACTIONS]: '_actions',
-            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
-            [ShortcutSection.FILE_LIST]: '_fileList',
-            [ShortcutSection.DIFFS]: '_diffs',
-          };
-        },
-      },
     };
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
index 69e2989..24438eb 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
@@ -102,12 +102,21 @@
     suffixForDashboard: 'limit:10',
   },
   {
+    // Changes where the user is in the attention set.
+    name: 'Your Turn',
+    query: 'attention:${user}',
+    hideIfEmpty: false,
+    suffixForDashboard: 'limit:25',
+    attentionSetOnly: true,
+  },
+  {
     // Changes that are assigned to the viewed user.
     name: 'Assigned reviews',
     query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
         'is:open -is:ignored',
     hideIfEmpty: true,
     suffixForDashboard: 'limit:25',
+    assigneeOnly: true,
   },
   {
     // WIP open changes owned by viewing user. This section is omitted when
@@ -730,8 +739,14 @@
   },
 
   getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
-      title = '') {
+      title = '', config = {}) {
+    const attentionEnabled =
+        config.change && !!config.change.enable_attention_set;
+    const assigneeEnabled =
+        config.change && !!config.change.enable_assignee;
     sections = sections
+        .filter(section => (attentionEnabled || !section.attentionSetOnly))
+        .filter(section => (assigneeEnabled || !section.assigneeOnly))
         .filter(section => (user === 'self' || !section.selfOnly))
         .map(section => Object.assign({}, section, {
           name: section.name,
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
deleted file mode 100644
index 6ac9c20..0000000
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.html
+++ /dev/null
@@ -1,103 +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">
-<meta charset="utf-8">
-<title>gr-app-it_test</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="element">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../test/common-test-setup.js';
-import './gr-app.js';
-import {util} from '../scripts/util.js';
-import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-app custom dark theme tests', () => {
-  let sandbox;
-  let element;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-reporting', {
-      appStarted: sandbox.stub(),
-    });
-    stub('gr-account-dropdown', {
-      _getTopContent: sinon.stub(),
-    });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {
-            js_resource_paths: [],
-            html_resource_paths: [
-              new URL('test/plugin.html', window.location.href).toString(),
-            ],
-          },
-        });
-      },
-      getVersion() { return Promise.resolve(42); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    window.localStorage.setItem('dark-theme', 'true');
-
-    element = fixture('element');
-
-    const importSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForAll,
-        '_import');
-    const importForThemeSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForTheme,
-        '_import');
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-          .then(() => {
-            flush(done);
-          });
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-        util.getComputedStyleValue('--primary-text-color', element),
-        'red');
-    assert.equal(
-        util.getComputedStyleValue('--header-background-color', element),
-        'black');
-    assert.equal(
-        util.getComputedStyleValue('--footer-background-color', element),
-        'yellow');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
new file mode 100644
index 0000000..cc955ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../utils/dom-util.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-app.js';
+import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {removeTheme} from '../styles/themes/dark-theme.js';
+
+const basicFixture = fixtureFromElement('gr-app');
+
+suite('gr-app custom dark theme tests', () => {
+  let sandbox;
+  let element;
+  setup(done => {
+    window.localStorage.setItem('dark-theme', 'true');
+    sandbox = sinon.sandbox.create();
+    element = basicFixture.instantiate();
+    pluginLoader.loadPlugins([]);
+    pluginLoader.awaitPluginsLoaded().then(() => flush(done));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    window.localStorage.removeItem('dark-theme');
+    removeTheme();
+  });
+
+  test('should tried to load dark theme', () => {
+    assert.isTrue(
+        !!document.head.querySelector('#dark-theme')
+    );
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        getComputedStyleValue('--header-background-color', element)
+            .toLowerCase(),
+        '#3b3d3f');
+    assert.equal(
+        getComputedStyleValue('--footer-background-color', element)
+            .toLowerCase(),
+        '#3b3d3f');
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.html b/polygerrit-ui/app/elements/custom-light-theme_test.html
deleted file mode 100644
index f8a749c..0000000
--- a/polygerrit-ui/app/elements/custom-light-theme_test.html
+++ /dev/null
@@ -1,103 +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">
-<meta charset="utf-8">
-<title>gr-app-it_test</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="element">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../test/common-test-setup.js';
-import './gr-app.js';
-import {util} from '../scripts/util.js';
-import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-app custom light theme tests', () => {
-  let sandbox;
-  let element;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-reporting', {
-      appStarted: sandbox.stub(),
-    });
-    stub('gr-account-dropdown', {
-      _getTopContent: sinon.stub(),
-    });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {
-            js_resource_paths: [],
-            html_resource_paths: [
-              new URL('test/plugin.html', window.location.href).toString(),
-            ],
-          },
-        });
-      },
-      getVersion() { return Promise.resolve(42); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    window.localStorage.removeItem('dark-theme');
-
-    element = fixture('element');
-
-    const importSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForAll,
-        '_import');
-    const importForThemeSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForTheme,
-        '_import');
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-          .then(() => {
-            flush(done);
-          });
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-        util.getComputedStyleValue('--primary-text-color', element),
-        '#F00BAA');
-    assert.equal(
-        util.getComputedStyleValue('--header-background-color', element),
-        '#F01BAA');
-    assert.equal(
-        util.getComputedStyleValue('--footer-background-color', element),
-        '#F02BAA');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
new file mode 100644
index 0000000..a866bb3
--- /dev/null
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../utils/dom-util.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-app.js';
+import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-app');
+
+suite('gr-app custom light theme tests', () => {
+  let sandbox;
+  let element;
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    window.localStorage.removeItem('dark-theme');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve({}); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+    pluginLoader.loadPlugins([]);
+    pluginLoader.awaitPluginsLoaded().then(() => flush(done));
+  });
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('should not load dark theme', () => {
+    assert.isFalse(!!document.head.querySelector('#dark-theme'));
+    assert.isTrue(!!document.head.querySelector('#light-theme'));
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        getComputedStyleValue('--header-background-color', element)
+            .toLowerCase(),
+        '#f1f3f4');
+    assert.equal(
+        getComputedStyleValue('--footer-background-color', element)
+            .toLowerCase(),
+        'transparent');
+  });
+});
\ No newline at end of file
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 248209a..04f1548 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
@@ -324,15 +324,20 @@
   }
 
   if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
-    const button = this._createElement('button');
-    button.tabIndex = -1;
-    td.appendChild(button);
-
     // Both td and button need a number of classes/attributes for various
     // selectors to work.
     this._decorateLineEl(td, number, side);
     td.classList.add('lineNum');
+
+    if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      return td;
+    }
+
+    const button = this._createElement('button');
+    td.appendChild(button);
+    button.tabIndex = -1;
     this._decorateLineEl(button, number, side);
+
     button.classList.add('lineNumButton');
 
     button.textContent = number === 'FILE' ? 'File' : number;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index acb8f0c..9d68ba3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -202,9 +202,10 @@
     }
   }
 
-  moveToNextChunk(opt_clipToTop) {
+  moveToNextChunk(opt_clipToTop, opt_navigateToNextFile) {
     this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-        target => target.parentNode.scrollHeight, opt_clipToTop);
+        target => target.parentNode.scrollHeight, opt_clipToTop,
+        opt_navigateToNextFile);
     this._fixSide();
   }
 
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 71fc341..440b233 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
@@ -244,6 +244,21 @@
     assert.equal(cursorElement.side, 'left');
   });
 
+  test('navigate to next unreviewed file via moveToNextChunk', () => {
+    const cursor = cursorElement.shadowRoot.querySelector('#cursorManager');
+    cursor.index = cursor.stops.length - 1;
+    const dispatchEventStub = sandbox.stub(cursor, 'dispatchEvent');
+    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
+        /* opt_navigateToNextFile = */true);
+    assert.isTrue(dispatchEventStub.called);
+    assert.equal(dispatchEventStub.getCall(0).args[0].type, 'show-alert');
+
+    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
+        /* opt_navigateToNextFile = */true);
+    assert.equal(dispatchEventStub.getCall(1).args[0].type,
+        'navigate-to-next-unreviewed-file');
+  });
+
   test('initialLineNumber not provided', done => {
     let scrollBehaviorDuringMove;
     const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
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 229ae43..665e4a6 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
@@ -119,6 +119,30 @@
     return null;
   }
 
+  _toggleRangeElHighlight(threadEl, highlightRange = false) {
+    // We don't want to re-create the line just for highlighting the range which
+    // is creating annoying bugs: @see Issue 12934
+    // As gr-ranged-comment-layer now does not notify the layer re-render and
+    // lack of access to the thread or the lineEl from the ranged-comment-layer,
+    // need to update range class for styles here.
+    const currentLine = threadEl.assignedSlot.parentElement.previousSibling;
+    if (currentLine && currentLine.querySelector) {
+      if (highlightRange) {
+        const rangeNode = currentLine.querySelector('.range');
+        if (rangeNode) {
+          rangeNode.classList.add('rangeHighlight');
+          rangeNode.classList.remove('range');
+        }
+      } else {
+        const rangeNode = currentLine.querySelector('.rangeHighlight');
+        if (rangeNode) {
+          rangeNode.classList.remove('rangeHighlight');
+          rangeNode.classList.add('range');
+        }
+      }
+    }
+  }
+
   _handleCommentThreadMouseenter(e) {
     const threadEl = this._getThreadEl(e);
     const index = this._indexForThreadEl(threadEl);
@@ -126,6 +150,8 @@
     if (index !== undefined) {
       this.set(['commentRanges', index, 'hovering'], true);
     }
+
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
   }
 
   _handleCommentThreadMouseleave(e) {
@@ -135,6 +161,8 @@
     if (index !== undefined) {
       this.set(['commentRanges', index, 'hovering'], false);
     }
+
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
   }
 
   _indexForThreadEl(threadEl) {
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 19abdaf..cf89c63 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
@@ -556,7 +556,8 @@
       this.$.cursor.moveToNextCommentThread();
     } else {
       if (this.modifierPressed(e)) { return; }
-      this.$.cursor.moveToNextChunk();
+      this.$.cursor.moveToNextChunk(/* opt_clipToTop = */false,
+          /* opt_navigateToNextFile = */true);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
index 3d30f77..2b7d363 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
@@ -422,6 +422,7 @@
   <gr-diff-cursor
     id="cursor"
     scroll-top-margin="[[_scrollTopMargin]]"
+    on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
   ></gr-diff-cursor>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
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 0510d3e..142143d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -433,7 +433,8 @@
     }
 
     return Array.from(
-        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'));
+        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'))
+        .filter(tr => tr.querySelector('button'));
   }
 
   /** @return {boolean} */
@@ -827,6 +828,15 @@
         const commentSide = threadEl.getAttribute('comment-side');
         const lineEl = this.$.diffBuilder.getLineElByNumber(
             lineNumString, commentSide);
+        // When the line the comment refers to does not exist, log an error
+        // but don't crash. This can happen e.g. if the API does not fully
+        // validate e.g. (robot) comments
+        if (lineEl == undefined) {
+          console.error(
+              'thread attached to line ', commentSide, lineNumString,
+              ' which does not exist.');
+          continue;
+        }
         const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
         const threadGroupEl = this._getOrCreateThreadGroup(
             contentEl, commentSide);
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 3bf3697..c00c0fb 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -25,6 +25,7 @@
 import {htmlTemplate} from './gr-patch-range-select_html.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {appContext} from '../../../services/app-context.js';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -78,6 +79,11 @@
     ];
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   _getShaForPatch(patch) {
     return patch.sha.substring(0, 10);
   }
@@ -285,8 +291,14 @@
     const target = dom(e).localTarget;
 
     if (target === this.$.patchNumDropdown) {
+      if (detail.patchNum === e.detail.value) return;
+      this.reporting.reportInteraction('right-patchset-changed',
+          {previous: detail.patchNum, current: e.detail.value});
       detail.patchNum = e.detail.value;
     } else {
+      if (detail.basePatchNum === e.detail.value) return;
+      this.reporting.reportInteraction('left-patchset-changed',
+          {previous: detail.basePatchNum, current: e.detail.value});
       detail.basePatchNum = e.detail.value;
     }
 
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 4128d54..c774f1a 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
@@ -137,10 +137,11 @@
     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});
-            });
+        this._updateRangesMap({
+          side, range, hovering,
+          operation: (forLine, start, end, hovering) => {
+            forLine.push({start, end, hovering});
+          }});
       }
     }
 
@@ -151,12 +152,13 @@
       // not the index, especially in polymer 1.
       const {side, range, hovering} = this.get(match[1]);
 
-      this._updateRangesMap(
-          side, range, hovering, (forLine, start, end, hovering) => {
-            const index = forLine.findIndex(lineRange =>
-              lineRange.start === start && lineRange.end === end);
-            forLine[index].hovering = hovering;
-          });
+      this._updateRangesMap({
+        side, range, hovering, skipLayerUpdate: true,
+        operation: (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.
@@ -164,26 +166,40 @@
       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);
-              });
+          this._updateRangesMap({
+            side, range, hovering, operation: (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});
-              });
+          this._updateRangesMap({
+            side, range, hovering,
+            operation: (forLine, start, end, hovering) => {
+              forLine.push({start, end, hovering});
+            }});
         }
       }
     }
   }
 
-  _updateRangesMap(side, range, hovering, operation) {
+  /**
+   * @param {!Object} options
+   * @property {!string} options.side
+   * @property {boolean} options.hovering
+   * @property {boolean} options.skipLayerUpdate
+   * @property {!Function} options.operation
+   * @property {!{
+   *  start_character: number,
+   *  start_line: number,
+   *  end_line: number,
+   *  end_character: number}} options.range
+   */
+  _updateRangesMap(options) {
+    const {side, range, hovering, operation, skipLayerUpdate} = options;
     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] = []);
@@ -191,7 +207,9 @@
       const end = line === range.end_line ? range.end_character : -1;
       operation(forLine, start, end, hovering);
     }
-    this._notifyUpdateRange(range.start_line, range.end_line, side);
+    if (!skipLayerUpdate) {
+      this._notifyUpdateRange(range.start_line, range.end_line, side);
+    }
   }
 
   _getRangesForLine(line, side) {
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 ff1f4a7..37d1707 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
@@ -214,11 +214,8 @@
 
     element.set(['commentRanges', 1, 'hovering'], true);
 
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 10);
-    assert.equal(lastCall.args[1], 12);
-    assert.equal(lastCall.args[2], 'right');
+    // notify will be skipped for hovering
+    assert.isFalse(notifyStub.called);
 
     assert.isTrue(updateRangesMapSpy.called);
   });
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index ecb40a5..91af97c 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -16,6 +16,7 @@
  */
 import '../styles/shared-styles.js';
 import '../styles/themes/app-theme.js';
+import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme.js';
 import './admin/gr-admin-view/gr-admin-view.js';
 import './documentation/gr-documentation-search/gr-documentation-search.js';
 import './change-list/gr-change-list-view/gr-change-list-view.js';
@@ -202,9 +203,7 @@
     });
 
     if (window.localStorage.getItem('dark-theme')) {
-      // No need to add the style module to element again as it's imported
-      // by importHref already
-      this.$.libLoader.getDarkTheme();
+      applyDarkTheme();
     }
 
     // Note: this is evaluated here to ensure that it only happens after the
@@ -499,6 +498,10 @@
     }
   }
 
+  handleShowKeyboardShortcuts() {
+    this.$.keyboardShortcuts.open();
+  }
+
   _showKeyboardShortcuts(e) {
     // same shortcut should close the dialog if pressed again
     // when dialog is open
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
index 47139ce..75295bc 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.js
+++ b/polygerrit-ui/app/elements/gr-app-element_html.js
@@ -104,6 +104,7 @@
       id="mainHeader"
       search-query="{{params.query}}"
       on-mobile-search="_mobileSearchToggle"
+      on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
       login-url="[[_loginUrl]]"
     >
     </gr-main-header>
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
index 2c166f5..8c1161c 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.js
@@ -50,7 +50,7 @@
 import {util} from '../scripts/util.js';
 import page from 'page/page.mjs';
 import {Auth} from './shared/gr-rest-api-interface/gr-auth.js';
-import {EventEmitter} from './shared/gr-event-interface/gr-event-interface.js';
+import {appContext} from '../services/app-context.js';
 import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
 import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
 import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
@@ -104,7 +104,7 @@
   window.util = util;
   window.page = page;
   window.Auth = Auth;
-  window.EventEmitter = EventEmitter;
+  window.EventEmitter = appContext.eventEmitter;
   window.GrAdminApi = GrAdminApi;
   window.GrAnnotationActionsContext = GrAnnotationActionsContext;
   window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
diff --git a/polygerrit-ui/app/elements/gr-app-init.js b/polygerrit-ui/app/elements/gr-app-init.js
index 780e64a..ea10ce8 100644
--- a/polygerrit-ui/app/elements/gr-app-init.js
+++ b/polygerrit-ui/app/elements/gr-app-init.js
@@ -20,7 +20,6 @@
 
 if (!window.Polymer) {
   window.Polymer = {
-    passiveTouchGestures: true,
     lazyRegister: true,
   };
 }
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
deleted file mode 100644
index 1483f7a..0000000
--- a/polygerrit-ui/app/elements/gr-app.html
+++ /dev/null
@@ -1 +0,0 @@
-<script src='./gr-app.js' type='module'></script>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 410539c..e0e7fa9 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -26,8 +26,9 @@
  * on older touch device.
  * See https://github.com/Polymer/polymer/issues/5289
  */
-import {setCancelSyntheticClickEvents} from '@polymer/polymer/lib/utils/settings.js';
+import {setPassiveTouchGestures, setCancelSyntheticClickEvents} from '@polymer/polymer/lib/utils/settings.js';
 setCancelSyntheticClickEvents(false);
+setPassiveTouchGestures(true);
 
 import 'polymer-resin/standalone/polymer-resin.js';
 import {initGlobalVariables} from './gr-app-global-var-init.js';
@@ -38,6 +39,7 @@
 import {htmlTemplate} from './gr-app_html.js';
 import {SafeTypes} from '../behaviors/safe-types-behavior/safe-types-behavior.js';
 import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit.js';
+import {appContext} from '../services/app-context.js';
 
 security.polymer_resin.install({
   allowedIdentifierPrefixes: [''],
@@ -57,4 +59,4 @@
 customElements.define(GrApp.is, GrApp);
 
 initGlobalVariables();
-initGerritPluginApi();
+initGerritPluginApi(appContext);
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 4822163..93cbcf5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -27,7 +27,12 @@
   if (opt_moduleName) {
     return endpointName + ' ' + opt_moduleName;
   } else {
-    return this._plugin.getPluginName() + '-autogenerated-' + endpointName;
+    // lowercase in case plugin's name contains uppercase letters
+    // TODO: this still can not prevent if plugin has invalid char
+    // other than uppercase, but is the first step
+    // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
+    const pluginName = this._plugin.getPluginName() || 'unknown_plugin';
+    return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
   }
 };
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 4991770..c91819a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {importHref} from '../../../scripts/import-href.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
@@ -65,15 +64,6 @@
     pluginEndpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
   }
 
-  /**
-   * @suppress {checkTypes}
-   */
-  _import(url) {
-    return new Promise((resolve, reject) => {
-      importHref(url, resolve, reject);
-    });
-  }
-
   _initDecoration(name, plugin, slot) {
     const el = document.createElement(name);
     return this._initProperties(el, plugin,
@@ -133,9 +123,9 @@
             `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
         }, INIT_PROPERTIES_TIMEOUT_MS));
     return Promise.race([timeout, Promise.all(expectProperties)])
-        .then(() => {
-          clearTimeout(timeoutId);
-          return el;
+        .then(() => el)
+        .finally(() => {
+          if (timeoutId) clearTimeout(timeoutId);
         });
   }
 
@@ -174,10 +164,7 @@
     pluginEndpoints.onNewEndpoint(this.name, this._endpointCallBack);
     if (this.name) {
       pluginLoader.awaitPluginsLoaded()
-          .then(() => Promise.all(
-              pluginEndpoints.getPlugins(this.name).map(
-                  pluginUrl => this._import(pluginUrl)))
-          )
+          .then(() => pluginEndpoints.getAndImportPlugins(this.name))
           .then(() =>
             pluginEndpoints
                 .getDetails(this.name)
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
similarity index 72%
rename from polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
rename to polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 890a457..17ada2c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -1,61 +1,51 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-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-endpoint-decorator</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div>
-      <gr-endpoint-decorator name="first">
-        <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-        <p>
-          <span>test slot</span>
-          <gr-endpoint-slot name="test"></gr-endpoint-slot>
-        </p>
-      </gr-endpoint-decorator>
-      <gr-endpoint-decorator name="second">
-        <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-      <gr-endpoint-decorator name="banana">
-        <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup-karma.js';
 import './gr-endpoint-decorator.js';
 import '../gr-endpoint-param/gr-endpoint-param.js';
 import '../gr-endpoint-slot/gr-endpoint-slot.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
+const basicFixture = fixtureFromTemplate(
+    html`<div>
+  <gr-endpoint-decorator name="first">
+    <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+    <p>
+      <span>test slot</span>
+      <gr-endpoint-slot name="test"></gr-endpoint-slot>
+    </p>
+  </gr-endpoint-decorator>
+  <gr-endpoint-decorator name="second">
+    <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
+  </gr-endpoint-decorator>
+  <gr-endpoint-decorator name="banana">
+    <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
+  </gr-endpoint-decorator>
+</div>`
+);
+
 suite('gr-endpoint-decorator', () => {
   let container;
   let sandbox;
@@ -66,11 +56,9 @@
 
   setup(done => {
     sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
     resetPlugins();
-    container = fixture('basic');
+    container = basicFixture.instantiate();
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
     pluginApi.install(p => plugin = p, '0.1',
         'http://some/plugin/url.html');
     // Decoration
@@ -90,16 +78,16 @@
 
   teardown(() => {
     sandbox.restore();
+    resetPlugins();
   });
 
   test('imports plugin-provided modules into endpoints', () => {
     const endpoints =
         Array.from(container.querySelectorAll('gr-endpoint-decorator'));
     assert.equal(endpoints.length, 3);
-    endpoints.forEach(element => {
-      assert.isTrue(
-          element._import.calledWith(new URL('http://some/plugin/url.html')));
-    });
+    assert.isTrue(pluginEndpoints.importUrl.calledWith(
+        new URL('http://some/plugin/url.html')
+    ));
   });
 
   test('decoration', () => {
@@ -124,7 +112,7 @@
   test('decoration with slot', () => {
     const element =
         container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = [...dom(element).querySelectorAll('p > some-module-2')];
+    const modules = [...dom(element).querySelectorAll('some-module-2')];
     assert.equal(modules.length, 1);
     const [module] = modules;
     assert.isOk(module);
@@ -225,4 +213,3 @@
     });
   });
 });
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index f27053d..16176b3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {importHref} from '../../../scripts/import-href.js';
 import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
@@ -35,10 +34,6 @@
   static get properties() {
     return {
       name: String,
-      _urlsImported: {
-        type: Array,
-        value() { return []; },
-      },
       _stylesApplied: {
         type: Array,
         value() { return []; },
@@ -46,23 +41,6 @@
     };
   }
 
-  _importHref(url, resolve, reject) {
-    // It is impossible to mock es6-module imported function.
-    // The _importHref function is mocked in test.
-    importHref(url, resolve, reject);
-  }
-
-  /**
-   * @suppress {checkTypes}
-   */
-  _import(url) {
-    if (this._urlsImported.includes(url)) { return Promise.resolve(); }
-    this._urlsImported.push(url);
-    return new Promise((resolve, reject) => {
-      this._importHref(url, resolve, reject);
-    });
-  }
-
   _applyStyle(name) {
     if (this._stylesApplied.includes(name)) { return; }
     this._stylesApplied.push(name);
@@ -79,14 +57,13 @@
   }
 
   _importAndApply() {
-    Promise.all(pluginEndpoints.getPlugins(this.name).map(
-        pluginUrl => this._import(pluginUrl))
-    ).then(() => {
-      const moduleNames = pluginEndpoints.getModules(this.name);
-      for (const name of moduleNames) {
-        this._applyStyle(name);
-      }
-    });
+    pluginEndpoints.getAndImportPlugins(this.name)
+        .then(() => {
+          const moduleNames = pluginEndpoints.getModules(this.name);
+          for (const name of moduleNames) {
+            this._applyStyle(name);
+          }
+        });
   }
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
deleted file mode 100644
index 8f85348..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ /dev/null
@@ -1,133 +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-external-style</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <gr-external-style name="foo"></gr-external-style>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-external-style.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some/plugin/url.html';
-
-  let sandbox;
-  let element;
-  let plugin;
-  let importHrefStub;
-
-  const installPlugin = () => {
-    if (plugin) { return; }
-    pluginApi.install(p => {
-      plugin = p;
-    }, '0.1', TEST_URL);
-  };
-
-  const createElement = () => {
-    element = fixture('basic');
-    sandbox.spy(element, '_applyStyle');
-  };
-
-  /**
-   * Installs the plugin, creates the element, registers style module.
-   */
-  const lateRegister = () => {
-    installPlugin();
-    createElement();
-    plugin.registerStyleModule('foo', 'some-module');
-  };
-
-  /**
-   * Installs the plugin, registers style module, creates the element.
-   */
-  const earlyRegister = () => {
-    installPlugin();
-    plugin.registerStyleModule('foo', 'some-module');
-    createElement();
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    importHrefStub = sandbox.stub().callsArg(1);
-    stub('gr-external-style', {
-      _importHref: (url, resolve, reject) => {
-        importHrefStub(url, resolve, reject);
-      },
-    });
-    sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
-        .returns(Promise.resolve());
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('imports plugin-provided module', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
-  });
-
-  test('applies plugin-provided styles', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-
-  test('does not double import', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    const urlsImported =
-        element._urlsImported.filter(url => url.toString() === TEST_URL);
-    assert.strictEqual(urlsImported.length, 1);
-  });
-
-  test('does not double apply', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    const stylesApplied =
-        element._stylesApplied.filter(name => name === 'some-module');
-    assert.strictEqual(stylesApplied.length, 1);
-  });
-
-  test('loads and applies preloaded modules', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
new file mode 100644
index 0000000..50e08cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-external-style.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-external-style name="foo"></gr-external-style>`
+);
+
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some/plugin/url.html';
+
+  let sandbox;
+  let element;
+  let plugin;
+
+  const installPlugin = () => {
+    if (plugin) { return; }
+    pluginApi.install(p => {
+      plugin = p;
+    }, '0.1', TEST_URL);
+  };
+
+  const createElement = () => {
+    element = basicFixture.instantiate();
+    sandbox.spy(element, '_applyStyle');
+  };
+
+  /**
+   * Installs the plugin, creates the element, registers style module.
+   */
+  const lateRegister = () => {
+    installPlugin();
+    createElement();
+    plugin.registerStyleModule('foo', 'some-module');
+  };
+
+  /**
+   * Installs the plugin, registers style module, creates the element.
+   */
+  const earlyRegister = () => {
+    installPlugin();
+    plugin.registerStyleModule('foo', 'some-module');
+    createElement();
+  };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
+    sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  test('imports plugin-provided module', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(pluginEndpoints.importUrl.calledWith(new URL(TEST_URL)));
+  });
+
+  test('applies plugin-provided styles', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+
+  test('does not double import', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    // since loaded, should not call again
+    assert.isFalse(pluginEndpoints.importUrl.calledOnce);
+  });
+
+  test('does not double apply', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    const stylesApplied =
+        element._stylesApplied.filter(name => name === 'some-module');
+    assert.strictEqual(stylesApplied.length, 1);
+  });
+
+  test('loads and applies preloaded modules', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index 1833efa..46e37e1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -37,9 +37,10 @@
 
   _configChanged(config) {
     const plugins = config.plugin;
-    const htmlPlugins = (plugins.html_resource_paths || []);
-    const jsPlugins =
-        this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
+    const htmlPlugins = (plugins && plugins.html_resource_paths || []);
+    const jsPlugins = this._handleMigrations(
+        plugins && plugins.js_resource_paths || [], htmlPlugins
+    );
     const shouldLoadTheme = config.default_theme &&
           !pluginLoader.isPluginPreloaded('preloaded:gerrit-theme');
     const themeToLoad =
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
index 752570f..1a2cd28 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
@@ -14,20 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../admin/gr-repo-command/gr-repo-command.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-Polymer({
-  _template: html`
-    <gr-repo-command title="[[title]]">
-    </gr-repo-command>
-`,
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-repo-command_html.js';
 
-  is: 'gr-plugin-repo-command',
+class GrPluginRepoCommand extends PolymerElement {
+  static get is() {
+    return 'gr-plugin-repo-command';
+  }
 
-  properties: {
-    title: String,
-    repoName: String,
-    config: Object,
-  },
-});
+  static get properties() {
+    return {
+      title: String,
+      repoName: String,
+      config: Object,
+    };
+  }
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  _handleClick() {
+    this.dispatchEvent(
+        new CustomEvent('command-tap', {composed: true, bubbles: true})
+    );
+  }
+}
+
+customElements.define(GrPluginRepoCommand.is, GrPluginRepoCommand);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
similarity index 83%
rename from polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
rename to polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
index 50aca6d..1e5088b3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
@@ -23,12 +23,6 @@
       margin-bottom: var(--spacing-xxl);
     }
   </style>
-  <h3 class="heading-3">[[title]]</h3>
-  <gr-button
-    title$="[[tooltip]]"
-    disabled$="[[disabled]]"
-    on-click="_onCommandTap"
-  >
-    [[title]]
-  </gr-button>
+  <h3>[[title]]</h3>
+  <gr-button on-click="_handleClick">[[title]]</gr-button>
 `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index 32ae959..1af91fd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -73,13 +73,12 @@
       const pluginCommand = element.shadowRoot
           .querySelector('gr-plugin-repo-command');
       assert.isOk(pluginCommand);
-      const command = pluginCommand.shadowRoot
-          .querySelector('gr-repo-command');
-      assert.isOk(command);
-      assert.equal(command.title, 'foo');
+      const btn = pluginCommand.shadowRoot
+          .querySelector('gr-button');
+      assert.isOk(btn);
+      assert.equal(btn.textContent, 'foo');
       assert.isFalse(tapStub.called);
-      MockInteractions.tap(command.shadowRoot
-          .querySelector('gr-button'));
+      MockInteractions.tap(btn);
       assert.isTrue(tapStub.called);
       done();
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 95f7a2c..3b889c4 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -20,6 +20,7 @@
 import '../../../styles/gr-menu-page-styles.js';
 import '../../../styles/gr-page-nav-styles.js';
 import '../../../styles/shared-styles.js';
+import {applyTheme as applyDarkTheme, removeTheme as removeDarkTheme} from '../../../styles/themes/dark-theme.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../gr-change-table-editor/gr-change-table-editor.js';
 import '../../shared/gr-button/gr-button.js';
@@ -68,8 +69,6 @@
 const ABSOLUTE_URL_PATTERN = /^https?:/;
 const TRAILING_SLASH_PATTERN = /\/$/;
 
-const RELOAD_MESSAGE = 'Reloading...';
-
 const HTTP_AUTH = [
   'HTTP',
   'HTTP_LDAP',
@@ -475,17 +474,19 @@
   _handleToggleDark() {
     if (this._isDark) {
       window.localStorage.removeItem('dark-theme');
+      removeDarkTheme();
     } else {
       window.localStorage.setItem('dark-theme', 'true');
+      applyDarkTheme();
     }
+    this._isDark = !!window.localStorage.getItem('dark-theme');
     this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {message: RELOAD_MESSAGE},
+      detail: {
+        message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
+      },
       bubbles: true,
       composed: true,
     }));
-    this.async(() => {
-      window.location.reload();
-    }, 1);
   }
 
   _showHttpAuth(config) {
@@ -497,6 +498,13 @@
 
     return false;
   }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapDarkToggle(e) {
+    e.preventDefault();
+  }
 }
 
 customElements.define(GrSettingsView.is, GrSettingsView);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
index e32a551..0e0f86c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
@@ -107,6 +107,7 @@
             aria-labelledby="darkThemeToggleLabel"
             checked="[[_isDark]]"
             on-change="_handleToggleDark"
+            on-tap="_onTapDarkToggle"
           ></paper-toggle-button>
           <div id="darkThemeToggleLabel">Dark theme (alpha)</div>
         </div>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
similarity index 90%
rename from polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
rename to polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index e430ecb..09ad0e3 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -1,46 +1,28 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
 
-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">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-settings-view></gr-settings-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import './gr-settings-view.js';
 import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-settings-view');
+const blankFixture = fixtureFromElement('div');
+
 suite('gr-settings-view tests', () => {
   let element;
   let account;
@@ -112,7 +94,7 @@
       getConfig() { return Promise.resolve(config); },
       getAccountGroups() { return Promise.resolve([]); },
     });
-    element = fixture('basic');
+    element = basicFixture.instantiate();
 
     // Allow the element to render.
     element._loadingPromise.then(done);
@@ -122,6 +104,20 @@
     sandbox.restore();
   });
 
+  test('theme changing', () => {
+    window.localStorage.removeItem('dark-theme');
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+    const themeToggle = element.shadowRoot
+        .querySelector('.darkToggle paper-toggle-button');
+    MockInteractions.tap(themeToggle);
+    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
+    assert.equal(
+        getComputedStyleValue('--primary-text-color', document.body), '#e8eaed'
+    );
+    MockInteractions.tap(themeToggle);
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+  });
+
   test('calls the title-change event', () => {
     const titleChangedStub = sandbox.stub();
 
@@ -129,8 +125,7 @@
     const newElement = document.createElement('gr-settings-view');
     newElement.addEventListener('title-change', titleChangedStub);
 
-    // Attach it to the fixture.
-    const blank = fixture('blank');
+    const blank = blankFixture.instantiate();
     blank.appendChild(newElement);
 
     flush();
@@ -528,5 +523,4 @@
       resolveConfirm('bar');
     });
   });
-});
-</script>
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index c74a396..7e40f48 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -48,6 +48,12 @@
   static get properties() {
     return {
       account: Object,
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
       voteableText: String,
       disabled: {
         type: Boolean,
@@ -58,7 +64,12 @@
         type: Boolean,
         value: false,
       },
-      showAttention: {
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
       },
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
index ee19374..7c75488 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
@@ -83,7 +83,8 @@
   <div class$="container [[_getBackgroundClass(transparentBackground)]]">
     <gr-account-link
       account="[[account]]"
-      show-attention="[[showAttention]]"
+      change="[[change]]"
+      highlight-attention="[[highlightAttention]]"
       voteable-text="[[voteableText]]"
     >
     </gr-account-link>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 8c2b7da..844b7f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -44,8 +44,19 @@
        * @type {{ name: string, status: string }}
        */
       account: Object,
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
       voteableText: String,
-      showAttention: {
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
       },
@@ -57,7 +68,10 @@
         type: Boolean,
         value: false,
       },
-      _serverConfig: {
+      /**
+       * This is a ServerInfo response object.
+       */
+      _config: {
         type: Object,
         value: null,
       },
@@ -67,8 +81,22 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.restAPI.getConfig()
-        .then(config => { this._serverConfig = config; });
+    this.$.restAPI.getConfig().then(config => { this._config = config; });
+  }
+
+  get isAttentionSetEnabled() {
+    return !!this._config && !!this._config.change
+        && !!this._config.change.enable_attention_set
+        && !!this.highlightAttention && !!this.change && !!this.account;
+  }
+
+  get hasAttention() {
+    if (!this.isAttentionSetEnabled || !this.change.attention_set) return false;
+    return this.change.attention_set.hasOwnProperty(this.account._account_id);
+  }
+
+  _computeShowAttentionIcon(config, highlightAttention, account, change) {
+    return this.isAttentionSetEnabled && this.hasAttention;
   }
 
   _computeName(account, config) {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
index ba2d9cb..12df5fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
@@ -49,7 +49,11 @@
       @apply --gr-account-label-text-hover-style;
     }
     iron-icon.attention {
+      width: 14px;
+      height: 14px;
       vertical-align: top;
+      position: relative;
+      top: 3px;
     }
     iron-icon.status {
       width: 14px;
@@ -62,22 +66,23 @@
   <div class="overlay"></div>
   <span>
     <gr-hovercard-account
-      attention="[[showAttention]]"
       account="[[account]]"
+      change="[[change]]"
+      highlight-attention="[[highlightAttention]]"
       voteable-text="[[voteableText]]"
     >
     </gr-hovercard-account>
-    <template is="dom-if" if="[[showAttention]]">
-      <iron-icon class="attention" icon="gr-icons:attention"></iron-icon
-      ><!--
-   --></template
-    ><!--
-   --><template is="dom-if" if="[[!hideAvatar]]"
-      ><!--
-     --><gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+    <template
+      is="dom-if"
+      if="[[_computeShowAttentionIcon(_config, highlightAttention, account, change)]]"
+    >
+      <iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
+    </template>
+    <template is="dom-if" if="[[!hideAvatar]]">
+      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
     </template>
     <span class="text">
-      <span class="name"> [[_computeName(account, _serverConfig)]]</span>
+      <span class="name">[[_computeName(account, _config)]]</span>
       <template is="dom-if" if="[[!hideStatus]]">
         <template is="dom-if" if="[[account.status]]">
           <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index d7dd88d..3844bea 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -41,7 +41,18 @@
     return {
       voteableText: String,
       account: Object,
-      showAttention: {
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
       },
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
index ce2dc9e..44afb84 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
@@ -42,10 +42,11 @@
   <span>
     <a href$="[[_computeOwnerLink(account)]]">
       <gr-account-label
-        show-attention="[[showAttention]]"
+        account="[[account]]"
+        change="[[change]]"
+        highlight-attention="[[highlightAttention]]"
         hide-avatar="[[hideAvatar]]"
         hide-status="[[hideStatus]]"
-        account="[[account]]"
         voteable-text="[[voteableText]]"
       >
       </gr-account-label>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 647f41b..4bb79c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -119,11 +119,15 @@
    *    sometimes different, used by the diff cursor.
    * @param {boolean=} opt_clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param {boolean=} opt_navigateToNextFile Navigate to next unreviewed file
+   *     if user presses next on the last diff chunk
    * @private
    */
 
-  next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
-    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
+  next(opt_condition, opt_getTargetHeight, opt_clipToTop,
+      opt_navigateToNextFile) {
+    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop,
+        opt_navigateToNextFile);
   }
 
   previous(opt_condition) {
@@ -265,9 +269,12 @@
    *    sometimes different, used by the diff cursor.
    * @param {boolean=} opt_clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param {boolean=} opt_navigateToNextFile Navigate to next unreviewed file
+   *     if user presses next on the last diff chunk
    * @private
    */
-  _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
+  _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop,
+      opt_navigateToNextFile) {
     if (!this.stops.length) {
       this.unsetCursor();
       return;
@@ -282,6 +289,32 @@
       newTarget = this.stops[newIndex];
     }
 
+    /*
+     * If user presses n on the last diff chunk, show a toast informing user
+     * that pressing n again will navigate them to next unreviewed file
+     */
+    if (opt_navigateToNextFile && this.index === newIndex) {
+      if (newIndex === this.stops.length - 1) {
+        if (this._displayedNavigateToNextFileToast) {
+          // reset for next file
+          this._displayedNavigateToNextFileToast = false;
+          this.dispatchEvent(new CustomEvent(
+              'navigate-to-next-unreviewed-file', {
+                composed: true, bubbles: true,
+              }));
+          return;
+        }
+        this._displayedNavigateToNextFileToast = true;
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {
+            message: 'Press n again to navigate to next unreviewed file',
+          },
+          composed: true, bubbles: true,
+        }));
+        return;
+      }
+    }
+
     this.index = newIndex;
     this.target = newTarget;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 8e7ea87..717275d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -292,7 +292,8 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        this.dispatchEvent(new CustomEvent('tap-item',
+            {detail: item, bubbles: true, composed: true}));
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js b/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
deleted file mode 100644
index cfe4c4f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {EventEmitter} from '../gr-event-interface/gr-event-interface.js';
-
-// TODO(dmfilippov): move to appContext
-export const gerritEventEmitter = new EventEmitter();
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
index 49aff7e..ac9bd62 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
@@ -19,7 +19,9 @@
 import '../../../styles/shared-styles.js';
 import '../gr-avatar/gr-avatar.js';
 import '../gr-button/gr-button.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -35,15 +37,111 @@
 
   static get properties() {
     return {
+      /**
+       * This is an AccountInfo response object.
+       */
       account: Object,
+      _selfAccount: Object,
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
+      /**
+       * Explains which labels the user can vote on and which score they can
+       * give.
+       */
       voteableText: String,
-      attention: {
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
-        reflectToAttribute: true,
+      },
+      /**
+       * This is a ServerInfo response object.
+       */
+      _config: {
+        type: Object,
+        value: null,
       },
     };
   }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
+  }
+
+  _computeText(account, selfAccount) {
+    if (!account || !selfAccount) return '';
+    return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
+  }
+
+  get isAttentionSetEnabled() {
+    return !!this._config && !!this._config.change
+        && !!this._config.change.enable_attention_set
+        && !!this.highlightAttention && !!this.change && !!this.account;
+  }
+
+  get hasAttention() {
+    if (!this.isAttentionSetEnabled || !this.change.attention_set) return false;
+    return this.change.attention_set.hasOwnProperty(this.account._account_id);
+  }
+
+  _computeShowLabelNeedsAttention(config, highlightAttention, account, change) {
+    return this.isAttentionSetEnabled && this.hasAttention;
+  }
+
+  _computeShowActionAddToAttentionSet(config, highlightAttn, account, change) {
+    return this.isAttentionSetEnabled && !this.hasAttention;
+  }
+
+  _computeShowActionRemoveFromAttentionSet(config, highlightAttention, account,
+      change) {
+    return this.isAttentionSetEnabled && this.hasAttention;
+  }
+
+  _handleClickAddToAttentionSet(e) {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message: 'Adding user to attention set. Will be reloading ...',
+        dismissOnNavigation: true,
+      },
+      composed: true,
+      bubbles: true,
+    }));
+    this.$.restAPI.addToAttentionSet(this.change._number,
+        this.account._account_id, 'manually added').then(obj => {
+      GerritNav.navigateToChange(this.change);
+    });
+    this.hide();
+  }
+
+  _handleClickRemoveFromAttentionSet(e) {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message: 'Removing user from attention set. Will be reloading ...',
+        dismissOnNavigation: true,
+      },
+      composed: true,
+      bubbles: true,
+    }));
+    this.$.restAPI.removeFromAttentionSet(this.change._number,
+        this.account._account_id, 'manually removed').then(obj => {
+      GerritNav.navigateToChange(this.change);
+    });
+    this.hide();
+  }
 }
 
 customElements.define(GrHovercardAccount.is, GrHovercardAccount);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
index bbf6a96..262b089 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
@@ -50,17 +50,18 @@
       border-top: 1px solid var(--border-color);
       padding: var(--spacing-s) var(--spacing-l);
       --gr-button: {
-        padding: var(--spacing-s) 0;
+        padding: var(--spacing-s) var(--spacing-m);
       }
     }
-    :host(:not([attention])) .attention {
-      display: none;
-    }
     .attention {
       background-color: var(--emphasis-color);
     }
     .attention iron-icon {
+      width: 14px;
+      height: 14px;
       vertical-align: top;
+      position: relative;
+      top: 3px;
     }
   </style>
   <div id="container" role="tooltip" tabindex="-1">
@@ -89,10 +90,46 @@
           <span class="value">[[voteableText]]</span>
         </div>
       </template>
-      <div class="attention">
-        <iron-icon icon="gr-icons:attention"></iron-icon>
-        <span>It is this user's turn to take action.</span>
-      </div>
+      <template
+        is="dom-if"
+        if="[[_computeShowLabelNeedsAttention(_config, highlightAttention, account, change)]]"
+      >
+        <div class="attention">
+          <iron-icon icon="gr-icons:attention"></iron-icon>
+          <span>
+            [[_computeText(account, _selfAccount)]] turn to take action.
+          </span>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change)]]"
+      >
+        <div class="action">
+          <gr-button
+            link=""
+            no-uppercase=""
+            on-click="_handleClickAddToAttentionSet"
+          >
+            Add to attention set
+          </gr-button>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change)]]"
+      >
+        <div class="action">
+          <gr-button
+            link=""
+            no-uppercase=""
+            on-click="_handleClickRemoveFromAttentionSet"
+          >
+            Remove from attention set
+          </gr-button>
+        </div>
+      </template>
     </template>
   </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
index 884e1b6..a321b43 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
@@ -39,6 +39,7 @@
 
   suite('gr-hovercard-account tests', () => {
     let element;
+    let sandbox;
     const ACCOUNT = {
       email: 'kermit@gmail.com',
       username: 'kermit',
@@ -47,7 +48,11 @@
     };
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
+      sandbox.stub(element.$.restAPI, 'getAccount').returns(
+          new Promise(resolve => { '2'; })
+      );
       element.account = Object.assign({}, ACCOUNT);
       element.show({});
       flushAsynchronousOperations();
@@ -58,6 +63,14 @@
           'Kermit The Frog');
     });
 
+    test('_computeText', () => {
+      let account = {_account_id: '1'};
+      const selfAccount = {_account_id: '1'};
+      assert.equal(element._computeText(account, selfAccount), 'Your');
+      account = {_account_id: '2'};
+      assert.equal(element._computeText(account, selfAccount), 'Their');
+    });
+
     test('account status is not shown if the property is not set', () => {
       assert.isNull(element.shadowRoot.querySelector('.status'));
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
index 1190908..571e49a 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
@@ -186,9 +186,9 @@
    * `mouseleave` event on the hovercard's `target` element (as long as the
    * user is not hovering over the hovercard).
    *
-   * @param {Event} e DOM Event (e.g. `mouseleave` event)
+   * @param {Event} opt_e DOM Event (e.g. `mouseleave` event)
    */
-  hide(e) {
+  hide(opt_e) {
     this._isScheduledToShow = false;
     if (!this._isShowing) {
       return;
@@ -197,9 +197,11 @@
     // If the user is now hovering over the hovercard or the user is returning
     // from the hovercard but now hovering over the target (to stop an annoying
     // flicker effect), just return.
-    if (e.toElement === this ||
-        (e.fromElement === this && e.toElement === this._target)) {
-      return;
+    if (opt_e) {
+      if (opt_e.toElement === this ||
+          (opt_e.fromElement === this && opt_e.toElement === this._target)) {
+        return;
+      }
     }
 
     // Mark that the hovercard is not visible and do not allow focusing
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
index 070ac02..5ffe028 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -55,6 +55,8 @@
       <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
@@ -97,7 +99,7 @@
       <!-- This is a custom PolyGerrit SVG -->
       <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
       <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
-      <g id="attention"><path d="M5.5 19 l9 0 c.67 0 1.27 -.33 1.63 -.84 L20.5 12 l-4.37 -6.16 c-.36 -.51 -.96 -.84 -1.63 -.84 l-9 0 L9 12 z"></path></g>
+      <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
       <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
index ef57ae9..ce65755 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
@@ -21,8 +21,8 @@
  */
 
 import {pluginLoader} from './gr-plugin-loader.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
 import {getRestAPI, send} from './gr-api-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 /**
  * Trigger the preinstalls for bundled plugins.
@@ -146,9 +146,11 @@
     return pluginLoader.isPluginLoaded(pathOrUrl);
   };
 
+  const eventEmitter = appContext.eventEmitter;
+
   // TODO(taoalpha): List all internal supported event names.
   // Also convert this to inherited class once we move Gerrit to class.
-  globalGerritObj._eventEmitter = gerritEventEmitter;
+  globalGerritObj._eventEmitter = eventEmitter;
   ['addListener',
     'dispatch',
     'emit',
@@ -180,7 +182,7 @@
      *   });
      * });
      */
-    globalGerritObj[method] = gerritEventEmitter[method]
-        .bind(gerritEventEmitter);
+    globalGerritObj[method] = eventEmitter[method]
+        .bind(eventEmitter);
   });
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 0727397..2c97df0 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -16,151 +16,201 @@
  */
 
 import {pluginLoader} from './gr-plugin-loader.js';
+import {importHref} from '../../../scripts/import-href.js';
 
 /** @constructor */
-export function GrPluginEndpoints() {
-  this._endpoints = {};
-  this._callbacks = {};
-  this._dynamicPlugins = {};
-}
-
-GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
-  if (!this._callbacks[endpoint]) {
-    this._callbacks[endpoint] = [];
+export class GrPluginEndpoints {
+  constructor() {
+    this._endpoints = {};
+    this._callbacks = {};
+    this._dynamicPlugins = {};
+    this._importedUrls = new Set();
   }
-  this._callbacks[endpoint].push(callback);
-};
 
-GrPluginEndpoints.prototype.onDetachedEndpoint = function(endpoint,
-    callback) {
-  if (this._callbacks[endpoint]) {
-    this._callbacks[endpoint] = this._callbacks[endpoint]
-        .filter(cb => cb !== callback);
-  }
-};
-
-GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin, opts) {
-  const {endpoint, slot, type, moduleName, domHook} = opts;
-  const existingModule = this._endpoints[endpoint].find(info =>
-    info.plugin === plugin &&
-      info.moduleName === moduleName &&
-      info.domHook === domHook &&
-      info.slot === slot
-  );
-  if (existingModule) {
-    return existingModule;
-  } else {
-    const newModule = {
-      moduleName,
-      plugin,
-      pluginUrl: plugin._url,
-      type,
-      domHook,
-      slot,
-    };
-    this._endpoints[endpoint].push(newModule);
-    return newModule;
-  }
-};
-
-/**
- * Register a plugin to an endpoint.
- *
- * Dynamic plugins are registered to a specific prefix, such as
- * 'change-list-header'. These plugins are then fetched by prefix to determine
- * which endpoints to dynamically add to the page.
- *
- * @param {Object} plugin
- * @param {Object} opts
- */
-GrPluginEndpoints.prototype.registerModule = function(plugin, opts) {
-  const {endpoint, dynamicEndpoint} = opts;
-  if (dynamicEndpoint) {
-    if (!this._dynamicPlugins[dynamicEndpoint]) {
-      this._dynamicPlugins[dynamicEndpoint] = new Set();
+  onNewEndpoint(endpoint, callback) {
+    if (!this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = [];
     }
-    this._dynamicPlugins[dynamicEndpoint].add(endpoint);
+    this._callbacks[endpoint].push(callback);
   }
-  if (!this._endpoints[endpoint]) {
-    this._endpoints[endpoint] = [];
-  }
-  const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
-  if (pluginLoader.arePluginsLoaded() && this._callbacks[endpoint]) {
-    this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
-  }
-};
 
-GrPluginEndpoints.prototype.getDynamicEndpoints = function(dynamicEndpoint) {
-  const plugins = this._dynamicPlugins[dynamicEndpoint];
-  if (!plugins) return [];
-  return Array.from(plugins);
-};
-
-/**
- * Get detailed information about modules registered with an extension
- * endpoint.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- *   type: (string|undefined),
- *   moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<{
- *   moduleName: string,
- *   plugin: Plugin,
- *   pluginUrl: String,
- *   type: EndpointType,
- *   domHook: !Object
- * }>}
- */
-GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
-  const type = opt_options && opt_options.type;
-  const moduleName = opt_options && opt_options.moduleName;
-  if (!this._endpoints[name]) {
-    return [];
+  onDetachedEndpoint(endpoint, callback) {
+    if (this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = this._callbacks[endpoint].filter(
+          cb => cb !== callback
+      );
+    }
   }
-  return this._endpoints[name]
-      .filter(item => (!type || item.type === type) &&
-                  (!moduleName || moduleName == item.moduleName));
-};
 
-/**
- * Get detailed module names for instantiating at the endpoint.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- *   type: (string|undefined),
- *   moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<string>}
- */
-GrPluginEndpoints.prototype.getModules = function(name, opt_options) {
-  const modulesData = this.getDetails(name, opt_options);
-  if (!modulesData.length) {
-    return [];
+  _getOrCreateModuleInfo(plugin, opts) {
+    const {endpoint, slot, type, moduleName, domHook} = opts;
+    const existingModule = this._endpoints[endpoint].find(
+        info =>
+          info.plugin === plugin &&
+        info.moduleName === moduleName &&
+        info.domHook === domHook &&
+        info.slot === slot
+    );
+    if (existingModule) {
+      return existingModule;
+    } else {
+      const newModule = {
+        moduleName,
+        plugin,
+        pluginUrl: plugin._url,
+        type,
+        domHook,
+        slot,
+      };
+      this._endpoints[endpoint].push(newModule);
+      return newModule;
+    }
   }
-  return modulesData.map(m => m.moduleName);
-};
 
-/**
- * Get .html plugin URLs with element and module definitions.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- *   type: (string|undefined),
- *   moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<!URL>}
- */
-GrPluginEndpoints.prototype.getPlugins = function(name, opt_options) {
-  const modulesData =
-        this.getDetails(name, opt_options).filter(
-            data => data.pluginUrl.pathname.includes('.html'));
-  if (!modulesData.length) {
-    return [];
+  /**
+   * Register a plugin to an endpoint.
+   *
+   * Dynamic plugins are registered to a specific prefix, such as
+   * 'change-list-header'. These plugins are then fetched by prefix to determine
+   * which endpoints to dynamically add to the page.
+   *
+   * @param {Object} plugin
+   * @param {Object} opts
+   */
+  registerModule(plugin, opts) {
+    const {endpoint, dynamicEndpoint} = opts;
+    if (dynamicEndpoint) {
+      if (!this._dynamicPlugins[dynamicEndpoint]) {
+        this._dynamicPlugins[dynamicEndpoint] = new Set();
+      }
+      this._dynamicPlugins[dynamicEndpoint].add(endpoint);
+    }
+    if (!this._endpoints[endpoint]) {
+      this._endpoints[endpoint] = [];
+    }
+    const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
+    if (pluginLoader.arePluginsLoaded() && this._callbacks[endpoint]) {
+      this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
+    }
   }
-  return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
-};
+
+  getDynamicEndpoints(dynamicEndpoint) {
+    const plugins = this._dynamicPlugins[dynamicEndpoint];
+    if (!plugins) return [];
+    return Array.from(plugins);
+  }
+
+  /**
+   * Get detailed information about modules registered with an extension
+   * endpoint.
+   *
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<{
+   *   moduleName: string,
+   *   plugin: Plugin,
+   *   pluginUrl: String,
+   *   type: EndpointType,
+   *   domHook: !Object
+   * }>}
+   */
+  getDetails(name, opt_options) {
+    const type = opt_options && opt_options.type;
+    const moduleName = opt_options && opt_options.moduleName;
+    if (!this._endpoints[name]) {
+      return [];
+    }
+    return this._endpoints[name].filter(
+        item =>
+          (!type || item.type === type) &&
+        (!moduleName || moduleName == item.moduleName)
+    );
+  }
+
+  /**
+   * Get detailed module names for instantiating at the endpoint.
+   *
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<string>}
+   */
+  getModules(name, opt_options) {
+    const modulesData = this.getDetails(name, opt_options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return modulesData.map(m => m.moduleName);
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   *
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<!URL>}
+   */
+  getPlugins(name, opt_options) {
+    const modulesData = this.getDetails(name, opt_options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
+  }
+
+  importUrl(pluginUrl) {
+    let timerId;
+    return Promise
+        .race([
+          new Promise((resolve, reject) => {
+            this._importedUrls.add(pluginUrl.href);
+            importHref(pluginUrl, resolve, reject);
+          }),
+          // Timeout after 3s
+          new Promise(r => timerId = setTimeout(r, 3000)),
+        ])
+        .finally(() => {
+          if (timerId) clearTimeout(timerId);
+        });
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   *
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<!Promise<void>>}
+   */
+  getAndImportPlugins(name, opt_options) {
+    return Promise.all(
+        this.getPlugins(name, opt_options).map(pluginUrl => {
+          if (this._importedUrls.has(pluginUrl.href)) {
+            return Promise.resolve();
+          }
+
+          // TODO: we will deprecate html plugins entirely
+          // for now, keep the original behavior and import
+          // only for html ones
+          if (pluginUrl && pluginUrl.pathname.endsWith('.html')) {
+            return this.importUrl(pluginUrl);
+          } else {
+            return Promise.resolve();
+          }
+        })
+    );
+  }
+}
 
 // TODO(dmfilippov): Convert to service and add to appContext
 export let pluginEndpoints = new GrPluginEndpoints();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
similarity index 76%
rename from polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
rename to polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
index 3494e99..e6767bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
@@ -1,32 +1,22 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-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">
-<meta charset="utf-8">
-<title>gr-plugin-endpoints</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
 import './gr-js-api-interface.js';
 import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
 import {pluginLoader} from './gr-plugin-loader.js';
@@ -68,10 +58,12 @@
         }
     );
     sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
+    sandbox.spy(instance, 'importUrl');
   });
 
   teardown(() => {
     sandbox.restore();
+    resetPlugins();
   });
 
   test('getDetails all', () => {
@@ -133,6 +125,14 @@
         instance.getPlugins('a-place'), [pluginFoo._url]);
   });
 
+  test('getAndImportPlugins', () => {
+    instance.getAndImportPlugins('a-place');
+    assert.isTrue(instance.importUrl.called);
+    assert.isTrue(instance.importUrl.calledOnce);
+    instance.getAndImportPlugins('a-place');
+    assert.isTrue(instance.importUrl.calledOnce);
+  });
+
   test('onNewEndpoint', () => {
     const newModuleStub = sandbox.stub();
     instance.onNewEndpoint('a-place', newModuleStub);
@@ -176,5 +176,4 @@
       },
     ]);
   });
-});
-</script>
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
index 6c5546e..8e2455e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -416,7 +416,7 @@
               () => {
                 reject(new Error(this._timeout()));
               }, PLUGIN_LOADING_TIMEOUT_MS)),
-        ]).then(() => {
+        ]).finally(() => {
           if (timerId) clearTimeout(timerId);
         });
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
index e9cd441..3c3fc6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../gr-js-api-interface/gr-js-api-interface.js';
-import {importHref} from '../../../scripts/import-href.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
@@ -23,7 +22,6 @@
 import {htmlTemplate} from './gr-lib-loader_html.js';
 
 const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
 
 /** @extends PolymerElement */
 class GrLibLoader extends GestureEventListeners(
@@ -76,28 +74,6 @@
   }
 
   /**
-   * Loads the dark theme document. Returns a promise that resolves with a
-   * custom-style DOM element.
-   *
-   * @return {!Promise<Element>}
-   * @suppress {checkTypes}
-   */
-  getDarkTheme() {
-    return new Promise((resolve, reject) => {
-      importHref(
-          this._getLibRoot() + DARK_THEME_PATH, () => {
-            const module = document.createElement('style');
-            module.setAttribute('include', 'dark-theme');
-            const cs = document.createElement('custom-style');
-            cs.appendChild(module);
-
-            resolve(cs);
-          },
-          reject);
-    });
-  }
-
-  /**
    * Execute callbacks awaiting the HLJS lib load.
    */
   _onHLJSLibLoaded() {
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
similarity index 68%
rename from polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
rename to polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
index f2e5e3d..6208252 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -1,39 +1,25 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
 
-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">
-<meta charset="utf-8">
-<title>gr-lib-loader</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-lib-loader></gr-lib-loader>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup-karma.js';
 import './gr-lib-loader.js';
+
+const basicFixture = fixtureFromElement('gr-lib-loader');
+
 suite('gr-lib-loader tests', () => {
   let sandbox;
   let element;
@@ -42,7 +28,7 @@
 
   setup(() => {
     sandbox = sinon.sandbox.create();
-    element = fixture('basic');
+    element = basicFixture.instantiate();
 
     loadStub = sandbox.stub(element, '_loadScript', () =>
       new Promise(resolve => resolveLoad = resolve)
@@ -144,5 +130,4 @@
       });
     });
   });
-});
-</script>
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
index 6663f07..50a837d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
+import {appContext} from '../../../services/app-context.js';
 
 const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
 const MAX_GET_TOKEN_RETRIES = 2;
@@ -34,6 +34,7 @@
     this._status = Auth.STATUS.UNDETERMINED;
     this._authCheckPromise = null;
     this._last_auth_check_time = Date.now();
+    this.eventEmitter = appContext.eventEmitter;
   }
 
   get baseUrl() {
@@ -83,7 +84,7 @@
     if (this._status === status) return;
 
     if (this._status === Auth.STATUS.AUTHED) {
-      gerritEventEmitter.emit('auth-error', {
+      this.eventEmitter.emit('auth-error', {
         message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
       });
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
index f919cbb..f3abdb0 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -28,7 +28,7 @@
 import '../../../test/common-test-setup.js';
 import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {Auth, authService} from './gr-auth.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
+import {appContext} from '../../../services/app-context.js';
 
 suite('gr-auth', () => {
   let auth;
@@ -161,7 +161,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.resolve({status: 403}));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isFalse(authed2);
           assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
@@ -179,7 +179,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.reject(new Error('random error')));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isFalse(authed2);
           assert.isTrue(emitStub.called);
@@ -197,7 +197,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.resolve({status: 204}));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isTrue(authed2);
           assert.isFalse(emitStub.called);
@@ -215,7 +215,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.reject(new Error('random error')));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isFalse(authed2);
           assert.isFalse(emitStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index de4f12d..963031a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -2325,6 +2325,26 @@
     });
   }
 
+  addToAttentionSet(changeNum, user, reason) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/attention',
+      body: {user, reason},
+      reportUrlAsIs: true,
+    });
+  }
+
+  removeFromAttentionSet(changeNum, user, reason) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: `/attention/${user}`,
+      anonymizedEndpoint: '/attention/*',
+      body: {reason},
+    });
+  }
+
   /**
    * @suppress {checkTypes}
    * Resulted in error: Promise.prototype.then does not match formal
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
deleted file mode 100644
index ecd9007..0000000
--- a/polygerrit-ui/app/elements/test/plugin.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('app-theme', 'myplugin-app-theme');
-      plugin.registerStyleModule('app-theme-light', 'myplugin-app-theme-light');
-      plugin.registerStyleModule('app-theme-dark', 'myplugin-app-theme-dark');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="myplugin-app-theme">
-  <template>
-    <style>
-      html {
-        --primary-text-color: #F00BAA;
-      }
-    </style>
-  </template>
-</dom-module>
-
-<dom-module id="myplugin-app-theme-light">
-  <template>
-    <style>
-      html {
-        --header-background-color: #F01BAA;
-        --header-title-content: "MyGerrit";
-        --footer-background-color: #F02BAA;
-      }
-    </style>
-  </template>
-</dom-module>
-
-<dom-module id="myplugin-app-theme-dark">
-  <template>
-    <style>
-      html {
-        --primary-text-color: red;
-        --header-background-color: black;
-        --header-title-content: "MyGerrit Dark";
-        --footer-background-color: yellow;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/embed/README.md b/polygerrit-ui/app/embed/README.md
index 8860878..4e1677de 100644
--- a/polygerrit-ui/app/embed/README.md
+++ b/polygerrit-ui/app/embed/README.md
@@ -10,4 +10,4 @@
 
 All supported attributes defined in `polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js`, you can pass them by just assigning them to the `gr-app` element.
 
-To customize the style of the diff, you can use `css variables`, all supported variables defined in `polygerrit-ui/app/styles/themes/app-theme.html` and `polygerrit-ui/app/styles/themes/dark-theme.html`.
+To customize the style of the diff, you can use `css variables`, all supported variables defined in `polygerrit-ui/app/styles/themes/app-theme.js` and `polygerrit-ui/app/styles/themes/dark-theme.js`.
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.js b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
index 8a7fb66..90110f0 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
@@ -24,7 +24,7 @@
   }
 
   /**
-   * @returns {string[]} array of all enabled experiments.
+   * @returns {!Array<string>} array of all enabled experiments.
    */
   get enabledExperiments() {
     return [];
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index 411c969..affa7f2 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -1,5 +1,5 @@
 {
-  "entrypoint": "elements/gr-app.html",
+  "shell": "elements/gr-app.js",
   "sources": [
     "behaviors/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index bc2830d..83860d5 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -8,10 +8,10 @@
         name: rule name
         srcs: source files
         outs: array with a single item - the output file name
-        entry_point: application entry-point
+        entry_point: application js entry-point
     """
 
-    app_name = entry_point.split(".html")[0].split("/").pop()  # eg: gr-app
+    app_name = entry_point.split(".js")[0].split("/").pop()  # eg: gr-app
 
     native.filegroup(
         name = app_name + "-full-src",
@@ -24,7 +24,7 @@
         name = app_name + "-bundle-js",
         srcs = [app_name + "-full-src"],
         config_file = ":rollup.config.js",
-        entry_point = "elements/" + app_name + ".js",
+        entry_point = entry_point,
         rollup_bin = "//tools/node_tools:rollup-bin",
         sourcemap = "hidden",
         deps = [
@@ -36,7 +36,6 @@
         name = name + "_app_sources",
         srcs = [
             app_name + "-bundle-js.js",
-            entry_point,
         ],
     )
 
@@ -46,15 +45,6 @@
     )
 
     native.filegroup(
-        name = name + "_theme_sources",
-        srcs = native.glob(
-            ["styles/themes/*.html"],
-            # app-theme.html already included via an import in gr-app.html.
-            exclude = ["styles/themes/app-theme.html"],
-        ),
-    )
-
-    native.filegroup(
         name = name + "_top_sources",
         srcs = [
             "favicon.ico",
@@ -68,7 +58,6 @@
         srcs = [
             name + "_app_sources",
             name + "_css_sources",
-            name + "_theme_sources",
             name + "_top_sources",
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
@@ -84,7 +73,6 @@
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
-            "for f in $(locations " + name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
             "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
diff --git a/polygerrit-ui/app/services/app-context-init.js b/polygerrit-ui/app/services/app-context-init.js
index 983314e..fa6a44bf 100644
--- a/polygerrit-ui/app/services/app-context-init.js
+++ b/polygerrit-ui/app/services/app-context-init.js
@@ -17,6 +17,7 @@
 import {appContext} from './app-context.js';
 import {FlagsService} from './flags.js';
 import {GrReporting} from './gr-reporting/gr-reporting.js';
+import {EventEmitter} from './gr-event-interface/gr-event-interface.js';
 
 const initializedServices = new Map();
 
@@ -46,6 +47,6 @@
   addService('flagsService', () => new FlagsService());
   addService('reportingService',
       () => new GrReporting(appContext.flagsService));
-
+  addService('eventEmitter', () => new EventEmitter());
   Object.defineProperties(appContext, registeredServices);
 }
diff --git a/polygerrit-ui/app/services/app-context.js b/polygerrit-ui/app/services/app-context.js
index 62b396d..d82750e 100644
--- a/polygerrit-ui/app/services/app-context.js
+++ b/polygerrit-ui/app/services/app-context.js
@@ -24,4 +24,5 @@
 export const appContext = {
   flagsService: null,
   reportingService: null,
+  eventEmitter: null,
 };
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js
similarity index 100%
rename from polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
rename to polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.html
similarity index 94%
rename from polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
rename to polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.html
index 74936ad..ef2c539 100644
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.html
@@ -30,10 +30,10 @@
 </test-fixture>
 
 <script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-js-api-interface/gr-js-api-interface.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
 import {EventEmitter} from './gr-event-interface.js';
-import {_testOnly_initGerritPluginApi} from '../gr-js-api-interface/gr-gerrit.js';
+import {_testOnly_initGerritPluginApi} from '../../elements/shared/gr-js-api-interface/gr-gerrit.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
diff --git a/polygerrit-ui/app/styles/themes/app-theme.js b/polygerrit-ui/app/styles/themes/app-theme.js
index 718d6a5..1a8296f 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.js
+++ b/polygerrit-ui/app/styles/themes/app-theme.js
@@ -16,217 +16,211 @@
  */
 const $_documentContainer = document.createElement('template');
 
-$_documentContainer.innerHTML = `<custom-style><style is="custom-style">
-html {
-  /**
-   * When adding a new color variable make sure to also add it to the other
-   * theme files in the same directory.
-   *
-   * For colors prefer lower case hex colors.
-   *
-   * Note that plugins might be using these variables, so removing a variable
-   * can be a breaking change that should go into the release notes.
-   */
-
-  /* text colors */
-  --primary-text-color: black;
-  --link-color: #2a66d9;
-  --comment-text-color: black;
-  --deemphasized-text-color: #5F6368;
-  --default-button-text-color: #2a66d9;
-  --error-text-color: red;
-  --primary-button-text-color: white;
-    /* Used on text color for change list that doesn't need user's attention. */
-  --reviewed-text-color: black;
-  --vote-text-color: black;
-  --status-text-color: white;
-  --tooltip-text-color: white;
-  --negative-red-text-color: #d93025;
-  --positive-green-text-color: #188038;
-  
-  /* background colors */
-  /* primary background colors */
-  --background-color-primary: #ffffff;
-  --background-color-secondary: #f8f9fa;
-  --background-color-tertiary: #f1f3f4;
-  /* directly derived from primary background colors */
-  --chip-background-color: var(--background-color-tertiary);
-  --default-button-background-color: var(--background-color-primary);
-  --dialog-background-color: var(--background-color-primary);
-  --dropdown-background-color: var(--background-color-primary);
-  --expanded-background-color: var(--background-color-tertiary);
-  --select-background-color: var(--background-color-secondary);
-  --shell-command-background-color: var(--background-color-secondary);
-  --shell-command-decoration-background-color: var(--background-color-tertiary);
-  --table-header-background-color: var(--background-color-secondary);
-  --table-subheader-background-color: var(--background-color-tertiary);
-  --view-background-color: var(--background-color-primary);
-  /* unique background colors */
-  --assignee-highlight-color: #fcfad6;
-  --edit-mode-background-color: #ebf5fb;
-  --emphasis-color: #fff9c4;
-  --hover-background-color: rgba(161, 194, 250, 0.2);
-  --disabled-button-background-color: #e8eaed;
-  --primary-button-background-color: #2a66d9;
-  --selection-background-color: rgba(161, 194, 250, 0.1);
-  --tooltip-background-color: #333;
-  /* comment background colors */
-  --comment-background-color: #e8eaed;
-  --robot-comment-background-color: #e8f0fe;
-  --unresolved-comment-background-color: #fef7e0;
-  /* vote background colors */
-  --vote-color-approved: #9fcc6b;
-  --vote-color-disliked: #f7c4cb;
-  --vote-color-neutral: #ebf5fb;
-  --vote-color-recommended: #c9dfaf;
-  --vote-color-rejected: #f7a1ad;
-
-  /* misc colors */
-  --border-color: #e8e8e8;
-  --comment-separator-color: #dadce0;
-
-  /* status colors */
-  --status-merged: #188038;
-  --status-abandoned: #5f6368;
-  --status-wip: #795548;
-  --status-private: #a142f4;
-  --status-conflict: #d93025;
-  --status-active: #1976d2;
-  --status-ready: #b80672;
-  --status-custom: #681da8;
-
-  /* fonts */
-  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
-  --font-size-code: 12px;     /* 12px mono */
-  --font-size-mono: .929rem;  /* 13px mono */
-  --font-size-small: .857rem; /* 12px */
-  --font-size-normal: 1rem;   /* 14px */
-  --font-size-h3: 1.143rem;   /* 16px */
-  --font-size-h2: 1.429rem;   /* 20px */
-  --font-size-h1: 1.714rem;   /* 24px */
-  --line-height-code: 1.334;      /* 16px */
-  --line-height-mono: 1.286rem;   /* 18px */
-  --line-height-small: 1.143rem;  /* 16px */
-  --line-height-normal: 1.429rem; /* 20px */
-  --line-height-h3: 1.714rem;     /* 24px */
-  --line-height-h2: 2rem;         /* 28px */
-  --line-height-h1: 2.286rem;     /* 32px */
-  --font-weight-normal: 400; /* 400 is the same as 'normal' */
-  --font-weight-bold: 500;
-  --font-weight-h1: 400;
-  --font-weight-h2: 400;
-  --font-weight-h3: 400;
-
-  /* spacing */
-  --spacing-xxs: 1px;
-  --spacing-xs: 2px;
-  --spacing-s: 4px;
-  --spacing-m: 8px;
-  --spacing-l: 12px;
-  --spacing-xl: 16px;
-  --spacing-xxl: 24px;
-
-  /* header and footer */
-  --footer-background-color: transparent;
-  --footer-border-top: none;
-  --header-background-color: var(--background-color-tertiary);
-  --header-border-bottom: 1px solid var(--border-color);
-  --header-border-image: '';
-  --header-box-shadow: none;
-  --header-padding: 0 var(--spacing-l);
-  --header-icon-size: 0em;
-  --header-icon: none;
-  --header-text-color: black;
-  --header-title-content: 'Gerrit';
-  --header-title-font-size: 1.75rem;
-
-  /* diff colors */
-  --dark-add-highlight-color: #aaf2aa;
-  --dark-rebased-add-highlight-color: #d7d7f9;
-  --dark-rebased-remove-highlight-color: #f7e8b7;
-  --dark-remove-highlight-color: #ffcdd2;
-  --diff-blank-background-color: var(--background-color-secondary);
-  --diff-context-control-background-color: #fff7d4;
-  --diff-context-control-border-color: #f6e6a5;
-  --diff-context-control-color: var(--deemphasized-text-color);
-  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
-  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
-  --diff-selection-background-color: #c7dbf9;
-  --diff-tab-indicator-color: var(--deemphasized-text-color);
-  --diff-trailing-whitespace-indicator: #ff9ad2;
-  --light-add-highlight-color: #d8fed8;
-  --light-rebased-add-highlight-color: #eef;
-  --light-remove-add-highlight-color: #fff8dc;
-  --light-remove-highlight-color: #ffebee;
-  --coverage-covered: #e0f2f1;
-  --coverage-not-covered: #ffd1a4;
-
-  /* syntax colors */
-  --syntax-attr-color: #219;
-  --syntax-attribute-color: var(--primary-text-color);
-  --syntax-built_in-color: #30a;
-  --syntax-comment-color: #3f7f5f;
-  --syntax-default-color: var(--primary-text-color);
-  --syntax-doctag-weight: bold;
-  --syntax-function-color: var(--primary-text-color);
-  --syntax-keyword-color: #9e0069;
-  --syntax-link-color: #219;
-  --syntax-literal-color: #219;
-  --syntax-meta-color: #ff1717;
-  --syntax-meta-keyword-color: #219;
-  --syntax-number-color: #164;
-  --syntax-params-color: var(--primary-text-color);
-  --syntax-regexp-color: #fa8602;
-  --syntax-selector-attr-color: #fa8602;
-  --syntax-selector-class-color: #164;
-  --syntax-selector-id-color: #2a00ff;
-  --syntax-selector-pseudo-color: #fa8602;
-  --syntax-string-color: #2a00ff;
-  --syntax-tag-color: #170;
-  --syntax-template-tag-color: #fa8602;
-  --syntax-template-variable-color: #0000c0;
-  --syntax-title-color: #0000c0;
-  --syntax-type-color: #2a66d9;
-  --syntax-variable-color: var(--primary-text-color);
-
-  /* elevation */
-  --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
-  --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
-  --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
-  --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
-  --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
-
-  /* misc */
-  --border-radius: 4px;
-  --reply-overlay-z-index: 1000;
-
-  /* paper and iron component overrides */
-  --iron-overlay-backdrop-background-color: black;
-  --iron-overlay-backdrop-opacity: 0.32;
-  --iron-overlay-backdrop: {
-    transition: none;
-  };
-}
-@media screen and (max-width: 50em) {
+$_documentContainer.innerHTML = `
+<custom-style id="light-theme"><style is="custom-style">
   html {
+    /**
+     * When adding a new color variable make sure to also add it to the other
+     * theme files in the same directory.
+     *
+     * For colors prefer lower case hex colors.
+     *
+     * Note that plugins might be using these variables, so removing a variable
+     * can be a breaking change that should go into the release notes.
+     */
+  
+    /* text colors */
+    --primary-text-color: black;
+    --link-color: #2a66d9;
+    --comment-text-color: black;
+    --deemphasized-text-color: #5F6368;
+    --default-button-text-color: #2a66d9;
+    --error-text-color: red;
+    --primary-button-text-color: white;
+      /* Used on text color for change list that doesn't need user's attention. */
+    --reviewed-text-color: black;
+    --vote-text-color: black;
+    --status-text-color: white;
+    --tooltip-text-color: white;
+    --negative-red-text-color: #d93025;
+    --positive-green-text-color: #188038;
+    
+    /* background colors */
+    /* primary background colors */
+    --background-color-primary: #ffffff;
+    --background-color-secondary: #f8f9fa;
+    --background-color-tertiary: #f1f3f4;
+    /* directly derived from primary background colors */
+    --chip-background-color: var(--background-color-tertiary);
+    --default-button-background-color: var(--background-color-primary);
+    --dialog-background-color: var(--background-color-primary);
+    --dropdown-background-color: var(--background-color-primary);
+    --expanded-background-color: var(--background-color-tertiary);
+    --select-background-color: var(--background-color-secondary);
+    --shell-command-background-color: var(--background-color-secondary);
+    --shell-command-decoration-background-color: var(--background-color-tertiary);
+    --table-header-background-color: var(--background-color-secondary);
+    --table-subheader-background-color: var(--background-color-tertiary);
+    --view-background-color: var(--background-color-primary);
+    /* unique background colors */
+    --assignee-highlight-color: #fcfad6;
+    --edit-mode-background-color: #ebf5fb;
+    --emphasis-color: #fff9c4;
+    --hover-background-color: rgba(161, 194, 250, 0.2);
+    --disabled-button-background-color: #e8eaed;
+    --primary-button-background-color: #2a66d9;
+    --selection-background-color: rgba(161, 194, 250, 0.1);
+    --tooltip-background-color: #333;
+    /* comment background colors */
+    --comment-background-color: #e8eaed;
+    --robot-comment-background-color: #e8f0fe;
+    --unresolved-comment-background-color: #fef7e0;
+    /* vote background colors */
+    --vote-color-approved: #9fcc6b;
+    --vote-color-disliked: #f7c4cb;
+    --vote-color-neutral: #ebf5fb;
+    --vote-color-recommended: #c9dfaf;
+    --vote-color-rejected: #f7a1ad;
+  
+    /* misc colors */
+    --border-color: #e8e8e8;
+    --comment-separator-color: #dadce0;
+  
+    /* status colors */
+    --status-merged: #188038;
+    --status-abandoned: #5f6368;
+    --status-wip: #795548;
+    --status-private: #a142f4;
+    --status-conflict: #d93025;
+    --status-active: #1976d2;
+    --status-ready: #b80672;
+    --status-custom: #681da8;
+  
+    /* fonts */
+    --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
+    --font-size-code: 12px;     /* 12px mono */
+    --font-size-mono: .929rem;  /* 13px mono */
+    --font-size-small: .857rem; /* 12px */
+    --font-size-normal: 1rem;   /* 14px */
+    --font-size-h3: 1.143rem;   /* 16px */
+    --font-size-h2: 1.429rem;   /* 20px */
+    --font-size-h1: 1.714rem;   /* 24px */
+    --line-height-code: 1.334;      /* 16px */
+    --line-height-mono: 1.286rem;   /* 18px */
+    --line-height-small: 1.143rem;  /* 16px */
+    --line-height-normal: 1.429rem; /* 20px */
+    --line-height-h3: 1.714rem;     /* 24px */
+    --line-height-h2: 2rem;         /* 28px */
+    --line-height-h1: 2.286rem;     /* 32px */
+    --font-weight-normal: 400; /* 400 is the same as 'normal' */
+    --font-weight-bold: 500;
+    --font-weight-h1: 400;
+    --font-weight-h2: 400;
+    --font-weight-h3: 400;
+  
+    /* spacing */
     --spacing-xxs: 1px;
-    --spacing-xs: 1px;
-    --spacing-s: 2px;
-    --spacing-m: 4px;
-    --spacing-l: 8px;
-    --spacing-xl: 12px;
-    --spacing-xxl: 16px;
+    --spacing-xs: 2px;
+    --spacing-s: 4px;
+    --spacing-m: 8px;
+    --spacing-l: 12px;
+    --spacing-xl: 16px;
+    --spacing-xxl: 24px;
+  
+    /* header and footer */
+    --footer-background-color: transparent;
+    --footer-border-top: none;
+    --header-background-color: var(--background-color-tertiary);
+    --header-border-bottom: 1px solid var(--border-color);
+    --header-border-image: '';
+    --header-box-shadow: none;
+    --header-padding: 0 var(--spacing-l);
+    --header-icon-size: 0em;
+    --header-icon: none;
+    --header-text-color: black;
+    --header-title-content: 'Gerrit';
+    --header-title-font-size: 1.75rem;
+  
+    /* diff colors */
+    --dark-add-highlight-color: #aaf2aa;
+    --dark-rebased-add-highlight-color: #d7d7f9;
+    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --dark-remove-highlight-color: #ffcdd2;
+    --diff-blank-background-color: var(--background-color-secondary);
+    --diff-context-control-background-color: #fff7d4;
+    --diff-context-control-border-color: #f6e6a5;
+    --diff-context-control-color: var(--deemphasized-text-color);
+    --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
+    --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+    --diff-selection-background-color: #c7dbf9;
+    --diff-tab-indicator-color: var(--deemphasized-text-color);
+    --diff-trailing-whitespace-indicator: #ff9ad2;
+    --light-add-highlight-color: #d8fed8;
+    --light-rebased-add-highlight-color: #eef;
+    --light-remove-add-highlight-color: #fff8dc;
+    --light-remove-highlight-color: #ffebee;
+    --coverage-covered: #e0f2f1;
+    --coverage-not-covered: #ffd1a4;
+  
+    /* syntax colors */
+    --syntax-attr-color: #219;
+    --syntax-attribute-color: var(--primary-text-color);
+    --syntax-built_in-color: #30a;
+    --syntax-comment-color: #3f7f5f;
+    --syntax-default-color: var(--primary-text-color);
+    --syntax-doctag-weight: bold;
+    --syntax-function-color: var(--primary-text-color);
+    --syntax-keyword-color: #9e0069;
+    --syntax-link-color: #219;
+    --syntax-literal-color: #219;
+    --syntax-meta-color: #ff1717;
+    --syntax-meta-keyword-color: #219;
+    --syntax-number-color: #164;
+    --syntax-params-color: var(--primary-text-color);
+    --syntax-regexp-color: #fa8602;
+    --syntax-selector-attr-color: #fa8602;
+    --syntax-selector-class-color: #164;
+    --syntax-selector-id-color: #2a00ff;
+    --syntax-selector-pseudo-color: #fa8602;
+    --syntax-string-color: #2a00ff;
+    --syntax-tag-color: #170;
+    --syntax-template-tag-color: #fa8602;
+    --syntax-template-variable-color: #0000c0;
+    --syntax-title-color: #0000c0;
+    --syntax-type-color: #2a66d9;
+    --syntax-variable-color: var(--primary-text-color);
+  
+    /* elevation */
+    --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
+    --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
+    --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
+    --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
+    --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
+  
+    /* misc */
+    --border-radius: 4px;
+    --reply-overlay-z-index: 1000;
+  
+    /* paper and iron component overrides */
+    --iron-overlay-backdrop-background-color: black;
+    --iron-overlay-backdrop-opacity: 0.32;
+    --iron-overlay-backdrop: {
+      transition: none;
+    };
   }
-}
+  @media screen and (max-width: 50em) {
+    html {
+      --spacing-xxs: 1px;
+      --spacing-xs: 1px;
+      --spacing-s: 2px;
+      --spacing-m: 4px;
+      --spacing-l: 8px;
+      --spacing-xl: 12px;
+      --spacing-xxl: 16px;
+    }
+  }
 </style></custom-style>`;
 
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
+document.head.appendChild($_documentContainer.content);
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.js
similarity index 82%
rename from polygerrit-ui/app/styles/themes/dark-theme.html
rename to polygerrit-ui/app/styles/themes/dark-theme.js
index 18b2fd6..684f2fe 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.js
@@ -1,24 +1,27 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
 
-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.
--->
-<dom-module id="dark-theme">
-  <custom-style><style is="custom-style">
+function getStyleEl() {
+  const $_documentContainer = document.createElement('template');
+  $_documentContainer.innerHTML = `
+  <custom-style id="dark-theme"><style is="custom-style">
     html {
       /**
-       * Sections and variables must stay consistent with app-theme.html.
+       * Sections and variables must stay consistent with app-theme.js.
        *
        * Only modify color variables in this theme file. dark-theme extends
        * app-theme, so there is no need to repeat all variables, but for colors
@@ -153,5 +156,18 @@
       /* rules applied to <html> */
       background-color: var(--view-background-color);
     }
-  </style></custom-style>
-</dom-module>
+  </style></custom-style>`;
+
+  return $_documentContainer;
+}
+
+export function applyTheme() {
+  document.head.appendChild(getStyleEl().content);
+}
+
+export function removeTheme() {
+  const darkThemeEls = document.head.querySelectorAll('#dark-theme');
+  if (darkThemeEls.length) {
+    darkThemeEls.forEach(darkThemeEl => darkThemeEl.remove());
+  }
+}
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index db0d279..5131b9d 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -91,16 +91,6 @@
   assert.equal(cleanups.length, 0);
 
   _testOnly_resetPluginLoader();
-
-  initAppContext();
-  function setMock(serviceName, setupMock) {
-    Object.defineProperty(appContext, serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('reportingService', grReportingMock);
 });
 
 if (isKarmaTest() || window.stub) {
@@ -125,6 +115,16 @@
   throw new Error('window.stub must be set after wct sets it');
 }
 
+initAppContext();
+function setMock(serviceName, setupMock) {
+  Object.defineProperty(appContext, serviceName, {
+    get() {
+      return setupMock;
+    },
+  });
+}
+setMock('reportingService', grReportingMock);
+
 teardown(() => {
   // WCT incorrectly uses teardown method in the 'fixture' and 'stub'
   // implementations. This leads to slowdown WCT tests after each tests.
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
index d072635..c866a16 100644
--- a/polygerrit-ui/app/test/tests.js
+++ b/polygerrit-ui/app/test/tests.js
@@ -41,7 +41,6 @@
   'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
   'admin/gr-plugin-list/gr-plugin-list_test.html',
   'admin/gr-repo-access/gr-repo-access_test.html',
-  'admin/gr-repo-command/gr-repo-command_test.html',
   'admin/gr-repo-commands/gr-repo-commands_test.html',
   'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
   'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
@@ -58,8 +57,6 @@
   'change-list/gr-repo-header/gr-repo-header_test.html',
   'change-list/gr-user-header/gr-user-header_test.html',
   'change/gr-change-actions/gr-change-actions_test.html',
-  'change/gr-change-metadata/gr-change-metadata-it_test.html',
-  'change/gr-change-metadata/gr-change-metadata_test.html',
   'change/gr-change-requirements/gr-change-requirements_test.html',
   'change/gr-comment-list/gr-comment-list_test.html',
   'change/gr-commit-info/gr-commit-info_test.html',
@@ -80,7 +77,6 @@
   'change/gr-messages-list/gr-messages-list_test.html',
   'change/gr-messages-list/gr-messages-list-experimental_test.html',
   'change/gr-related-changes-list/gr-related-changes-list_test.html',
-  'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
   'change/gr-reply-dialog/gr-reply-dialog_test.html',
   'change/gr-reviewer-list/gr-reviewer-list_test.html',
   'change/gr-thread-list/gr-thread-list_test.html',
@@ -123,9 +119,7 @@
   'plugins/gr-styles-api/gr-styles-api_test.html',
   'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
   'plugins/gr-dom-hooks/gr-dom-hooks_test.html',
-  'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
   'plugins/gr-event-helper/gr-event-helper_test.html',
-  'plugins/gr-external-style/gr-external-style_test.html',
   'plugins/gr-plugin-host/gr-plugin-host_test.html',
   'plugins/gr-popup-interface/gr-plugin-popup_test.html',
   'plugins/gr-popup-interface/gr-popup-interface_test.html',
@@ -144,10 +138,8 @@
   'settings/gr-identities/gr-identities_test.html',
   'settings/gr-menu-editor/gr-menu-editor_test.html',
   'settings/gr-registration-dialog/gr-registration-dialog_test.html',
-  'settings/gr-settings-view/gr-settings-view_test.html',
   'settings/gr-ssh-editor/gr-ssh-editor_test.html',
   'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
-  'shared/gr-event-interface/gr-event-interface_test.html',
   'shared/gr-account-entry/gr-account-entry_test.html',
   'shared/gr-account-label/gr-account-label_test.html',
   'shared/gr-account-list/gr-account-list_test.html',
@@ -182,12 +174,10 @@
   'shared/gr-js-api-interface/gr-gerrit_test.html',
   'shared/gr-js-api-interface/gr-plugin-action-context_test.html',
   'shared/gr-js-api-interface/gr-plugin-loader_test.html',
-  'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
   'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
   'shared/gr-fixed-panel/gr-fixed-panel_test.html',
   'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
   'shared/gr-label-info/gr-label-info_test.html',
-  'shared/gr-lib-loader/gr-lib-loader_test.html',
   'shared/gr-limited-text/gr-limited-text_test.html',
   'shared/gr-linked-chip/gr-linked-chip_test.html',
   'shared/gr-linked-text/gr-linked-text_test.html',
@@ -217,7 +207,6 @@
 // Behaviors tests.
 /* eslint-disable max-len */
 const behaviors = [
-  'base-url-behavior/base-url-behavior_test.html',
   'docs-url-behavior/docs-url-behavior_test.html',
   'dom-util-behavior/dom-util-behavior_test.html',
   'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
@@ -257,6 +246,7 @@
   'flags_test.html',
   'gr-reporting/gr-reporting_test.html',
   'gr-reporting/gr-reporting_mock_test.html',
+  'gr-event-interface/gr-event-interface_test.html',
 ];
 for (let file of services) {
   file = servicesPath + file;
diff --git a/resources/com/google/gerrit/httpd/raw/HostPage.html b/resources/com/google/gerrit/httpd/raw/HostPage.html
deleted file mode 100644
index c0d8446..0000000
--- a/resources/com/google/gerrit/httpd/raw/HostPage.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<html>
-  <head>
-    <title>Gerrit Code Review</title>
-    <meta name="gwt:property" content="locale=en_US" />
-    <script id="gerrit_hostpagedata"></script>
-    <style id="gerrit_sitecss" type="text/css"></style>
-    <link rel="shortcut icon" type="image/x-icon" href="favicon.ico" />
-  </head>
-  <body>
-    <div id="gerrit_topmenu"></div>
-    <div id="gerrit_header"></div>
-    <div id="gerrit_startinggerrit" style="margin-left: 10px;">
-      <p>Loading <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a> ...</p>
-      <noscript>
-        <p>Gerrit requires a JavaScript enabled browser.</p>
-      </noscript>
-    </div>
-    <div id="gerrit_body"></div>
-    <div style="clear: both">
-      <div id="gerrit_footer"></div>
-      <div id="gerrit_btmmenu"></div>
-    </div>
-    <iframe src="javascript:''" id="__gwt_historyFrame" tabIndex='-1' style="position:absolute;width:0;height:0;border:0"></iframe>
-    <script id="gerrit_module"></script>
-  </body>
-</html>
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index d162714..32ba0bc 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -139,7 +139,7 @@
   // Content between webcomponents-lite and the load of the main app element
   // run before polymer-resin is installed so may have security consequences.
   // Contact your local security engineer if you have any questions, and
-  // CC them on any changes that load content before gr-app.html.
+  // CC them on any changes that load content before gr-app.js.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
   {if $assetsPath and $assetsBundle}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 534cbdb..617c8d17 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -111,7 +111,9 @@
     {for $group in $commentFiles}
       <li style="{$fileLiStyle}">
         <p>
-          <a href="{$group.link}">{$group.title}:</a>
+          {if $group.link}<a href="{$group.link}">{/if}
+          {$group.title}:
+          {if $group.link}</a>{/if}
         </p>
 
         <ul style="{$ulStyle}">
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 2f5447b..9908ee8 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -189,7 +189,6 @@
         "repository": attr.string(default = MAVEN_CENTRAL),
         "sha1": attr.string(),
         "src_sha1": attr.string(),
-        "unsign": attr.bool(default = False),
         "exports": attr.string_list(),
         "deps": attr.string_list(),
         "_download_script": attr.label(default = Label("//tools:download_file.py")),
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index d49e700..ce5d62d 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -2,6 +2,8 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//:version.bzl", "GERRIT_VERSION")
 
+IN_TREE_BUILD_MODE = True
+
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
 
 PLUGIN_DEPS_NEVERLINK = ["//plugins:plugin-lib-neverlink"]
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 8748250..14c726e 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ae31ac9..bd323ba 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index dc25c80..3b059e5 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index d21f88c..b8fa132 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 1da5004..26dcd31 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -96,8 +96,8 @@
     # and httpasyncclient as necessary.
     maven_jar(
         name = "elasticsearch-rest-client",
-        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.7.1",
-        sha1 = "6d44a8e35c11df6883747200bcf46f476a1782b8",
+        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.0",
+        sha1 = "ab28f6110bdc7d2ec886e1d6ff29a6c8ee30b883",
     )
 
     maven_jar(