Merge "Upgrade guice to 4.2.3"
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 9a62f01..8887875 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -1,29 +1,38 @@
 :linkattrs:
-= Gerrit Code Review - End to end load tests
+= Gerrit Code Review - End to end tests
 
-This document provides a description of a Gerrit load test scenario implemented using the
+This document provides descriptions of Gerrit end-to-end (`e2e`) test scenarios implemented using
 link:https://gatling.io/[Gatling,role=external,window=_blank] framework.
 
 Similar scenarios have been successfully used to compare performance of different Gerrit versions
-or study the Gerrit response under different load profiles.
+or study the Gerrit response under different load profiles. Although mostly for load, scenarios can
+either be for link:https://gatling.io/load-testing-continuous-integration/[load or functional,role=external,window=_blank]
+(e2e) testing purposes. Functional scenarios may then reuse this framework and Gatling's usability
+features such as its protocols (more below) and
+link:https://en.wikipedia.org/wiki/Domain-specific_language[DSL,role=external,window=_blank].
+
+That cross test-scope reusability applies to both Gerrit core scenarios and non-core ones, such as
+for Gerrit plugins or other potential extensions. End-to-end testing may then include scopes like
+feature integration, deployment, smoke (and load) testing. These load and functional test scopes
+should remain orthogonal to the unit and component (aka Gerrit `IT`-suffixed or `acceptance`) ones.
+The term `acceptance` though may still be coined by organizations to target e2e functional testing.
 
 == What is Gatling?
 
-Gatling is a load testing tool which provides out of the box support for the HTTP protocol.
+Gatling is mostly a load testing tool which provides out of the box support for the HTTP protocol.
 Documentation on how to write an HTTP load test can be found
 link:https://gatling.io/docs/current/http/http_protocol/[here,role=external,window=_blank].
-
-However, in the scenario we are proposing, we are leveraging the
-link:https://github.com/GerritForge/gatling-git[Gatling Git extension,role=external,window=_blank]
-to run tests at Git protocol level.
+However, in the scenarios that were initially proposed, the
+link:https://github.com/GerritForge/gatling-git[Gatling Git extension,role=external,window=_blank] was
+leveraged to run tests at the Git protocol level.
 
 Gatling is written in Scala, but the abstraction provided by the Gatling DSL makes the scenarios
 implementation easy even without any Scala knowledge. The
 link:https://gitenterprise.me/2019/12/20/stress-your-gerrit-with-gatling/[Stress your Gerrit with Gatling,role=external,window=_blank]
 blog post has more introductory information.
 
-Examples of scenarios can be found in the `e2e-tests` directory. The files in that directory
-should be formatted using the mainstream
+Examples of scenarios can be found in the `e2e-tests` directory. The files in that directory should
+be formatted using the mainstream
 link:https://plugins.jetbrains.com/plugin/1347-scala[Scala plugin for IntelliJ,role=external,window=_blank].
 The latter is not mandatory but preferred for `sbt` and Scala IDE purposes in this project.
 
@@ -86,10 +95,11 @@
 === Input file
 
 The `CloneUsingBothProtocols` scenario is fed with the data coming from the
-`src/test/resources/data/CloneUsingBothProtocols.json` file. Such a file contains the commands and
-repository used during the load test. That file currently looks like below. This scenario serves
-as a simple example with no actual load in it. It can be used to test or validate the local setup.
-More complex scenarios can be further developed, under the `com.google.gerrit.scenarios` package.
+`src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json` file. Such a
+file contains the commands and repository used during the e2e test. That file currently looks like
+below. This scenario serves as a simple example with no actual load in it. It can be used to test
+or validate the local setup. More complex scenarios can be further developed, under the
+`com.google.gerrit.scenarios` package.
 
 ----
 [
@@ -111,9 +121,19 @@
 * `pull`
 * `push`
 
+=== Project and HTTP credentials
+
 The example above assumes that the `loadtest-repo` project exists in the Gerrit under test. The
-`HTTP Credentials` or password obtained from test user's `Settings` (in Gerrit) may be required, in
-`src/test/resources/application.conf`, depending on the above commands used.
+`CloneUsingBothProtocols` scenario already includes creating that project and deleting it once done
+with it. That scenario class can be used as an example of how a scenario can compose itself
+alongside other scenarios (here, `CreateProject` and `DeleteProject`).
+
+The `HTTP Credentials` or password obtained from test user's `Settings` (in Gerrit) may be
+required, in `src/test/resources/application.conf`, depending on the above commands used. That
+file's `http` section shows which shell environment variables can be used to set those credentials.
+
+Executing the `CloneUsingBothProtocols` scenario, as is, does require setting the http credentials.
+That is because of the aforementioned create/delete project (http) scenarios composed within it.
 
 == How to run tests
 
@@ -142,6 +162,27 @@
 docker run -it e2e-tests -s com.google.gerrit.scenarios.CloneUsingBothProtocols
 ----
 
+=== How to run non-core scenarios
+
+Locally adding non-core scenarios, for example from Gerrit plugins, is as simple as copying such
+files in. Copying is necessary over linking, unless running using Docker (above) is not required.
+Docker does not support links for files it has to copy over through the Dockerfile (here, the
+scenario files). Here is how to proceed for adding such external (e.g., plugin) scenario files in:
+
+----
+pushd e2e-tests/src/test/scala
+cp -r (or, ln -s) scalaPackageStructure .
+popd
+
+pushd e2e-tests/src/test/resources/data
+cp -r (or, ln -s) jsonFilesPackageStructure .
+popd
+----
+
+The destination folders above readily git-ignore every non-core scenario file added under them. If
+running using Docker, `e2e-tests/Dockerfile` may require another `COPY` line for the hereby added
+scenarios. Aforementioned `sbt` or `docker` commands can then be used to run the added tests.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 5974aca..2748413 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -65,8 +65,8 @@
 [[e2e]]
 === End-to-end tests
 
-<<dev-e2e-tests#,This document>> describes how load test scenarios are
-implemented using link:https://gatling.io/[`Gatling`].
+<<dev-e2e-tests#,This document>> describes how `e2e` (load or functional) test
+scenarios are implemented using link:https://gatling.io/[`Gatling`,role=external,window=_blank].
 
 
 == Local server
diff --git a/e2e-tests/src/test/resources/data/.gitignore b/e2e-tests/src/test/resources/data/.gitignore
new file mode 100644
index 0000000..7354459
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/.gitignore
@@ -0,0 +1,4 @@
+*
+!*/
+!/com/google/gerrit/scenarios/*
+!/.gitignore
diff --git a/e2e-tests/src/test/resources/data/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
similarity index 100%
rename from e2e-tests/src/test/resources/data/CloneUsingBothProtocols.json
rename to e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
new file mode 100644
index 0000000..2e54de5
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -0,0 +1,5 @@
+[
+  {
+    "url": "http://localhost:8080/a/projects/loadtest-repo"
+  }
+]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
new file mode 100644
index 0000000..9312fb4
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -0,0 +1,5 @@
+[
+  {
+    "url": "http://localhost:8080/a/projects/loadtest-repo/delete-project~delete"
+  }
+]
diff --git a/e2e-tests/src/test/resources/data/ReplayRecordsFromFeeder.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
similarity index 100%
rename from e2e-tests/src/test/resources/data/ReplayRecordsFromFeeder.json
rename to e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
diff --git a/e2e-tests/src/test/scala/.gitignore b/e2e-tests/src/test/scala/.gitignore
new file mode 100644
index 0000000..7354459
--- /dev/null
+++ b/e2e-tests/src/test/scala/.gitignore
@@ -0,0 +1,4 @@
+*
+!*/
+!/com/google/gerrit/scenarios/*
+!/.gitignore
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index c5a7cba..19fbf1b 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -15,18 +15,32 @@
 package com.google.gerrit.scenarios
 
 import io.gatling.core.Predef._
+import io.gatling.core.feeder.FileBasedFeederBuilder
 import io.gatling.core.structure.ScenarioBuilder
 
 import scala.concurrent.duration._
 
 class CloneUsingBothProtocols extends GitSimulation {
+  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).queue
 
   private val test: ScenarioBuilder = scenario(name)
       .feed(data)
-      .exec(request)
+      .exec(gitRequest)
+
+  private val createProject = new CreateProject
+  private val deleteProject = new DeleteProject
 
   setUp(
+    createProject.test.inject(
+      atOnceUsers(1)
+    ),
     test.inject(
+      nothingFor(1 second),
       constantUsersPerSec(1) during (2 seconds)
-    )).protocols(protocol)
+    ),
+    deleteProject.test.inject(
+      nothingFor(3 second),
+      atOnceUsers(1)
+    ),
+  ).protocols(gitProtocol, httpProtocol)
 }
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
new file mode 100644
index 0000000..58c8994
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
@@ -0,0 +1,32 @@
+// 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.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FileBasedFeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+
+class CreateProject extends GerritSimulation {
+  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).queue
+
+  val test: ScenarioBuilder = scenario(name)
+      .feed(data)
+      .exec(httpRequest)
+
+  setUp(
+    test.inject(
+      atOnceUsers(1)
+    )).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
new file mode 100644
index 0000000..4b723cb
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
@@ -0,0 +1,32 @@
+// 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.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FileBasedFeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+
+class DeleteProject extends GerritSimulation {
+  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).queue
+
+  val test: ScenarioBuilder = scenario(name)
+      .feed(data)
+      .exec(httpRequest)
+
+  setUp(
+    test.inject(
+      atOnceUsers(1)
+    )).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
new file mode 100644
index 0000000..b628bc7
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -0,0 +1,34 @@
+// 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.scenarios
+
+import com.github.barbasa.gatling.git.GatlingGitConfiguration
+import io.gatling.core.Predef._
+import io.gatling.http.Predef.http
+import io.gatling.http.protocol.HttpProtocolBuilder
+import io.gatling.http.request.builder.HttpRequestBuilder
+
+class GerritSimulation extends Simulation {
+  implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
+
+  private val path: String = this.getClass.getPackage.getName.replaceAllLiterally(".", "/")
+  protected val name: String = this.getClass.getSimpleName
+  protected val resource: String = s"data/$path/$name.json"
+
+  protected val httpRequest: HttpRequestBuilder = http(name).post("${url}")
+  protected val httpProtocol: HttpProtocolBuilder = http.basicAuth(
+    conf.httpConfiguration.userName,
+    conf.httpConfiguration.password)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index 4d5130f..e2f13a4 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -16,23 +16,18 @@
 
 import java.io.{File, IOException}
 
+import com.github.barbasa.gatling.git.GitRequestSession
 import com.github.barbasa.gatling.git.protocol.GitProtocol
 import com.github.barbasa.gatling.git.request.builder.GitRequestBuilder
-import com.github.barbasa.gatling.git.{GatlingGitConfiguration, GitRequestSession}
 import io.gatling.core.Predef._
-import io.gatling.core.feeder.FileBasedFeederBuilder
 import org.apache.commons.io.FileUtils
 import org.eclipse.jgit.hooks.CommitMsgHook
 
-class GitSimulation extends Simulation {
-
-  implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
+class GitSimulation extends GerritSimulation {
   implicit val postMessageHook: Option[String] = Some(s"hooks/${CommitMsgHook.NAME}")
 
-  protected val name: String = this.getClass.getSimpleName
-  protected val data: FileBasedFeederBuilder[Any]#F = jsonFile(s"data/$name.json").circular
-  protected val request = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
-  protected val protocol: GitProtocol = GitProtocol()
+  protected val gitRequest = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
+  protected val gitProtocol: GitProtocol = GitProtocol()
 
   after {
     Thread.sleep(5000)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 82342be..32df1b5 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -15,16 +15,18 @@
 package com.google.gerrit.scenarios
 
 import io.gatling.core.Predef._
+import io.gatling.core.feeder.FileBasedFeederBuilder
 import io.gatling.core.structure.ScenarioBuilder
 
 import scala.concurrent.duration._
 
 class ReplayRecordsFromFeeder extends GitSimulation {
+  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).circular
 
   private val test: ScenarioBuilder = scenario(name)
       .repeat(10000) {
         feed(data)
-            .exec(request)
+            .exec(gitRequest)
       }
 
   setUp(
@@ -34,6 +36,6 @@
       rampUsers(10) during (5 seconds),
       constantUsersPerSec(20) during (15 seconds),
       constantUsersPerSec(20) during (15 seconds) randomized
-    )).protocols(protocol)
+    )).protocols(gitProtocol)
       .maxDuration(60 seconds)
 }
diff --git a/java/com/google/gerrit/entities/AttentionSetUpdate.java b/java/com/google/gerrit/entities/AttentionSetUpdate.java
index bdadfc0..45588722 100644
--- a/java/com/google/gerrit/entities/AttentionSetUpdate.java
+++ b/java/com/google/gerrit/entities/AttentionSetUpdate.java
@@ -23,8 +23,9 @@
  * 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.AttentionSetInput} for the representation
- * in the API.
+ * <p>See {@link com.google.gerrit.extensions.api.changes.AddToAttentionSetInput} and {@link
+ * com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput} for the representation in
+ * the API.
  */
 @AutoValue
 public abstract class AttentionSetUpdate {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 43d7fc9..edd5411 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
@@ -24,6 +23,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -985,18 +985,23 @@
     if (isSelf(who)) {
       return isVisible();
     }
+    Set<Account.Id> accounts = null;
     try {
-      return Predicate.or(
-          parseAccount(who).stream()
-              .map(a -> visibleto(args.userFactory.create(a)))
-              .collect(toImmutableList()));
+      accounts = parseAccount(who);
     } catch (QueryParseException e) {
       if (e instanceof QueryRequiresAuthException) {
         throw e;
       }
-      // Otherwise continue: if it's not an account, maybe it's a group?
+    }
+    if (accounts != null) {
+      if (accounts.size() == 1) {
+        return visibleto(args.userFactory.create(Iterables.getOnlyElement(accounts)));
+      } else if (accounts.size() > 1) {
+        throw error(String.format("\"%s\" resolves to multiple accounts", who));
+      }
     }
 
+    // If its not an account, maybe its a group?
     Collection<GroupReference> suggestions = args.groupBackend.suggest(who, null);
     if (!suggestions.isEmpty()) {
       HashSet<AccountGroup.UUID> ids = new HashSet<>();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index fb09c9f..040e2eb 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1784,16 +1784,43 @@
     assertQuery(q + " visibleto:self", change2, change1);
 
     // Second user cannot see first user's private change
-    Account.Id user2 = createAccount("anotheruser");
+    Account.Id user2 = createAccount("user2");
     assertQuery(q + " visibleto:" + user2.get(), change1);
-    assertQuery(q + " visibleto:anotheruser", change1);
+    assertQuery(q + " visibleto:user2", change1);
 
     String g1 = createGroup("group1", "Administrators");
-    gApi.groups().id(g1).addMembers("anotheruser");
+    gApi.groups().id(g1).addMembers("user2");
     assertQuery(q + " visibleto:" + g1, change1);
 
     requestContext.setContext(newRequestContext(user2));
     assertQuery("is:visible", change1);
+
+    Account.Id user3 = createAccount("user3");
+
+    // Explicitly authenticate user2 and user3 so that display name gets set
+    AuthRequest authRequest = AuthRequest.forUser("user2");
+    authRequest.setDisplayName("Another User");
+    authRequest.setEmailAddress("user2@example.com");
+    accountManager.authenticate(authRequest);
+    authRequest = AuthRequest.forUser("user3");
+    authRequest.setDisplayName("Another User");
+    authRequest.setEmailAddress("user3@example.com");
+    accountManager.authenticate(authRequest);
+
+    // Switch to user3
+    requestContext.setContext(newRequestContext(user3));
+    Change change3 = insert(repo, newChange(repo), user3);
+    Change change4 = insert(repo, newChangePrivate(repo), user3);
+
+    // User3 can see both their changes and the first user's change
+    assertQuery(q + " visibleto:" + user3.get(), change4, change3, change1);
+
+    // User2 cannot see user3's private change
+    assertQuery(q + " visibleto:" + user2.get(), change3, change1);
+
+    // Query as user3 by display name matching user2 and user3; bad request
+    assertFailingQuery(
+        q + " visibleto:\"Another User\"", "\"Another User\" resolves to multiple accounts");
   }
 
   @Test
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
index 1ba8fd9..2e7f5d4 100644
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
@@ -25,12 +25,8 @@
   Gerrit.DisplayNameBehavior = {
     // TODO(dmfilippov) replace DisplayNameBehavior with GrDisplayNameUtils
 
-    /**
-     * enableEmail when true enables to fallback to using email if
-     * the account name is not avilable.
-     */
-    getUserName(config, account, enableEmail) {
-      return GrDisplayNameUtils.getUserName(config, account, enableEmail);
+    getUserName(config, account) {
+      return GrDisplayNameUtils.getUserName(config, account);
     },
 
     getGroupDisplayName(group) {
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
index 863f708..26022c4 100644
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
@@ -65,26 +65,26 @@
     const account = {
       name: 'test-name',
     };
-    assert.deepEqual(element.getUserName(config, account, true), 'test-name');
+    assert.equal(element.getUserName(config, account), 'test-name');
   });
 
   test('getUserName username only', () => {
     const account = {
       username: 'test-user',
     };
-    assert.deepEqual(element.getUserName(config, account, true), 'test-user');
+    assert.equal(element.getUserName(config, account), 'test-user');
   });
 
   test('getUserName email only', () => {
     const account = {
       email: 'test-user@test-url.com',
     };
-    assert.deepEqual(element.getUserName(config, account, true),
+    assert.equal(element.getUserName(config, account),
         'test-user@test-url.com');
   });
 
   test('getUserName returns not Anonymous Coward as the anon name', () => {
-    assert.deepEqual(element.getUserName(config, null, true), 'Anonymous');
+    assert.equal(element.getUserName(config, null), 'Anonymous');
   });
 
   test('getUserName for the config returning the anon name', () => {
@@ -93,7 +93,7 @@
         anonymous_coward_name: 'Test Anon',
       },
     };
-    assert.deepEqual(element.getUserName(config, null, true), 'Test Anon');
+    assert.equal(element.getUserName(config, null), 'Test Anon');
   });
 
   test('getGroupDisplayName', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 51888d6..a3db19f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -496,9 +496,7 @@
   }
 
   _getRebaseAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'rebase',
-        {rebaseOnCurrent: null}
-    );
+    return this._getRevisionAction(revisionActions, 'rebase', null);
   }
 
   _getRevisionAction(revisionActions, actionName, emptyActionValue) {
@@ -523,7 +521,7 @@
         .then(revisionActions => {
           if (!revisionActions) { return; }
 
-          this.revisionActions = this._updateRebaseAction(revisionActions);
+          this.revisionActions = revisionActions;
           this._sendShowRevisionActions({
             change: this.change,
             revisionActions,
@@ -548,18 +546,6 @@
     );
   }
 
-  _updateRebaseAction(revisionActions) {
-    if (revisionActions && revisionActions.rebase) {
-      revisionActions.rebase.rebaseOnCurrent =
-          !!revisionActions.rebase.enabled;
-      this._parentIsCurrent = !revisionActions.rebase.enabled;
-      revisionActions.rebase.enabled = true;
-    } else {
-      this._parentIsCurrent = true;
-    }
-    return revisionActions;
-  }
-
   _changeChanged() {
     this.reload();
   }
@@ -969,7 +955,8 @@
   }
 
   showRevertDialog() {
-    const query = 'submissionid:' + this.change.submission_id;
+    // The search is still broken if there is a " in the topic.
+    const query = `submissionid: "${this.change.submission_id}"`;
     /* A chromium plugin expects that the modifyRevertMsg hook will only
     be called after the revert button is pressed, hence we populate the
     revert dialog after revert button is pressed. */
@@ -1120,8 +1107,9 @@
   }
 
   _calculateDisabled(action, hasKnownChainState) {
-    if (action.__key === 'rebase' && hasKnownChainState === false) {
-      return true;
+    if (action.__key === 'rebase') {
+      // Rebase button is only disabled when change has no parent(s).
+      return hasKnownChainState === false;
     }
     return !action.enabled;
   }
@@ -1601,6 +1589,13 @@
     });
   }
 
+  _computeRebaseOnCurrent(revisionRebaseAction) {
+    if (revisionRebaseAction) {
+      return !!revisionRebaseAction.enabled;
+    }
+    return null;
+  }
+
   /**
    * Occasionally, a change created by a change action is not yet knwon to the
    * API for a brief time. Wait for the given change number to be recognized.
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
index 7aa5ecd..b66beed 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
@@ -101,7 +101,7 @@
         </gr-dropdown>
     </div>
     <gr-overlay id="overlay" with-backdrop="">
-      <gr-confirm-rebase-dialog id="confirmRebase" class="confirmDialog" change-number="[[change._number]]" on-confirm="_handleRebaseConfirm" on-cancel="_handleConfirmDialogCancel" branch="[[change.branch]]" has-parent="[[hasParent]]" rebase-on-current="[[_revisionRebaseAction.rebaseOnCurrent]]" hidden=""></gr-confirm-rebase-dialog>
+      <gr-confirm-rebase-dialog id="confirmRebase" class="confirmDialog" change-number="[[change._number]]" on-confirm="_handleRebaseConfirm" on-cancel="_handleConfirmDialogCancel" branch="[[change.branch]]" has-parent="[[hasParent]]" rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]" hidden=""></gr-confirm-rebase-dialog>
       <gr-confirm-cherrypick-dialog id="confirmCherrypick" class="confirmDialog" change-status="[[changeStatus]]" commit-message="[[commitMessage]]" commit-num="[[commitNum]]" on-confirm="_handleCherrypickConfirm" on-cancel="_handleConfirmDialogCancel" project="[[change.project]]" hidden=""></gr-confirm-cherrypick-dialog>
       <gr-confirm-cherrypick-conflict-dialog id="confirmCherrypickConflict" class="confirmDialog" on-confirm="_handleCherrypickConflictConfirm" on-cancel="_handleConfirmDialogCancel" hidden=""></gr-confirm-cherrypick-conflict-dialog>
       <gr-confirm-move-dialog id="confirmMove" class="confirmDialog" on-confirm="_handleMoveConfirm" on-cancel="_handleConfirmDialogCancel" project="[[change.project]]" hidden=""></gr-confirm-move-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 0d78fb4..f8215f9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -393,7 +393,7 @@
 
       action.enabled = false;
       assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
+          element._calculateDisabled(action, hasKnownChainState), false);
     });
 
     test('rebase change', done => {
@@ -416,7 +416,6 @@
         };
         assert.isTrue(fetchChangesStub.called);
         element._handleRebaseConfirm({detail: {base: '1234'}});
-        rebaseAction.rebaseOnCurrent = true;
         assert.deepEqual(fireActionStub.lastCall.args,
             ['/rebase', rebaseAction, true, {base: '1234'}]);
         done();
@@ -921,12 +920,13 @@
       });
 
       suite('revert change submitted together', () => {
+        let getChangesStub;
         setup(() => {
           element.change = {
-            submission_id: '199',
+            submission_id: '199 0',
             current_revision: '2000',
           };
-          sandbox.stub(element.$.restAPI, 'getChanges')
+          getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges')
               .returns(Promise.resolve([
                 {change_id: '12345678901234', topic: 'T', subject: 'random'},
                 {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -938,6 +938,7 @@
               .querySelector('gr-button[data-action-key="revert"]');
           MockInteractions.tap(revertButton);
           flush(() => {
+            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
             const confirmRevertDialog = element.$.confirmRevertDialog;
             const revertSingleChangeLabel = confirmRevertDialog
                 .shadowRoot.querySelector('.revertSingleChange');
@@ -947,7 +948,7 @@
                 'Revert single change');
             assert(revertSubmissionLabel.innerText.trim() ===
                 'Revert entire submission (2 Changes)');
-            let expectedMsg = 'Revert submission 199' + '\n\n' +
+            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
               'Reason for revert: <INSERT REASONING HERE>' + '\n' +
               'Reverted Changes:' + '\n' +
               '1234567890:random' + '\n' +
@@ -994,7 +995,7 @@
           flush(() => {
             const radioInputs = confirmRevertDialog.shadowRoot
                 .querySelectorAll('input[name="revertOptions"]');
-            const revertSubmissionMsg = 'Revert submission 199' + '\n\n' +
+            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' + '\n' +
             'Reverted Changes:' + '\n' +
             '1234567890:random' + '\n' +
@@ -1922,57 +1923,23 @@
       assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
     });
 
-    test('_updateRebaseAction sets _parentIsCurrent on no rebase', () => {
-      const currentRevisionActions = {
-        cherrypick: {
-          enabled: true,
-          label: 'Cherry Pick',
-          method: 'POST',
-          title: 'cherrypick',
-        },
+    test('_computeRebaseOnCurrent', () => {
+      const rebaseAction = {
+        enabled: true,
+        label: 'Rebase',
+        method: 'POST',
+        title: 'Rebase onto tip of branch or parent change',
       };
-      element._parentIsCurrent = undefined;
-      element._updateRebaseAction(currentRevisionActions);
-      assert.isTrue(element._parentIsCurrent);
-    });
 
-    test('_updateRebaseAction', () => {
-      const currentRevisionActions = {
-        cherrypick: {
-          enabled: true,
-          label: 'Cherry Pick',
-          method: 'POST',
-          title: 'cherrypick',
-        },
-        rebase: {
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        },
-      };
-      element._parentIsCurrent = undefined;
-
-      // Rebase enabled should always end up true.
       // When rebase is enabled initially, rebaseOnCurrent should be set to
       // true.
-      assert.equal(element._updateRebaseAction(currentRevisionActions),
-          currentRevisionActions);
+      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
 
-      assert.isTrue(currentRevisionActions.rebase.enabled);
-      assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
-      assert.isFalse(element._parentIsCurrent);
-
-      delete currentRevisionActions.rebase.enabled;
+      delete rebaseAction.enabled;
 
       // When rebase is not enabled initially, rebaseOnCurrent should be set to
       // false.
-      assert.equal(element._updateRebaseAction(currentRevisionActions),
-          currentRevisionActions);
-
-      assert.isTrue(currentRevisionActions.rebase.enabled);
-      assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
-      assert.isTrue(element._parentIsCurrent);
+      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 05afa35..d62ea04 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -292,7 +292,6 @@
       _loading: Boolean,
       /** @type {?} */
       _projectConfig: Object,
-      _rebaseOnCurrent: Boolean,
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
@@ -1837,8 +1836,9 @@
    * @param {!Object} change
    */
   _computeCopyTextForTitle(change) {
-    return `${change._number}: ${change.subject}` +
-     ` | https://${location.host}${this._computeChangeUrl(change)}`;
+    return `${change._number}: ${change.subject} | ` +
+     `${location.protocol}//${location.host}` +
+       `${this._computeChangeUrl(change)}`;
   }
 
   _toggleCommitCollapsed() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 4dc0e9b3..9f7dd69 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -1204,7 +1204,7 @@
         .returns('/change/123');
     assert.equal(
         element._computeCopyTextForTitle(change),
-        '123: test subject | https://localhost:8081/change/123'
+        `123: test subject | http://${location.host}/change/123`
     );
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index a74ec5f..57337fb 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -167,7 +167,7 @@
     return NaN;
   }
 
-  _computeReviewerTooltip(reviewer, change) {
+  _computeVoteableText(reviewer, change) {
     if (!change || !change.labels) { return ''; }
     const maxScores = [];
     const maxPermitted = this._getMaxPermittedScores(change);
@@ -181,11 +181,7 @@
         maxScores.push(`${label}`);
       }
     }
-    if (maxScores.length) {
-      return 'Votable: ' + maxScores.join(', ');
-    } else {
-      return '';
-    }
+    return maxScores.join(', ');
   }
 
   _reviewersChanged(changeRecord, owner) {
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 bf7db12..c5df61d 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
@@ -44,7 +44,7 @@
     </style>
     <div class="container">
       <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip class="reviewer" account="[[reviewer]]" on-remove="_handleRemove" additional-text="[[_computeReviewerTooltip(reviewer, change)]]" removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
+        <gr-account-chip class="reviewer" account="[[reviewer]]" on-remove="_handleRemove" voteable-text="[[_computeVoteableText(reviewer, change)]]" removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
         </gr-account-chip>
       </template>
       <gr-button class="hiddenReviewers" link="" hidden\$="[[!_hiddenReviewerCount]]" on-click="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 627fa10..32e2e9b6 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -329,13 +329,13 @@
       },
     };
     assert.strictEqual(
-        element._computeReviewerTooltip({_account_id: 1}, change),
-        'Votable: Bar');
+        element._computeVoteableText({_account_id: 1}, change),
+        'Bar');
     assert.strictEqual(
-        element._computeReviewerTooltip({_account_id: 7}, change),
-        'Votable: Foo: +2, Bar, FooBar');
+        element._computeVoteableText({_account_id: 7}, change),
+        'Foo: +2, Bar, FooBar');
     assert.strictEqual(
-        element._computeReviewerTooltip({_account_id: 2}, change),
+        element._computeVoteableText({_account_id: 2}, change),
         '');
   });
 
@@ -347,7 +347,7 @@
       },
     };
     assert.strictEqual(
-        element._computeReviewerTooltip({_account_id: 1}, change), '');
+        element._computeVoteableText({_account_id: 1}, change), '');
   });
 });
 </script>
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 6d9f9d7..444986b 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
@@ -124,7 +124,7 @@
   }
 
   _accountName(account) {
-    return this.getUserName(this.config, account, true);
+    return this.getUserName(this.config, account);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 55f8abd..ae5cb67 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -284,23 +284,17 @@
    */
   appStarted() {
     this.timeEnd(TIMING.APP_STARTED);
-    this.pageLoaded();
+    this._reportNavResTimes();
   },
 
   /**
-   * Page load time and other metrics, should be reported at any time
-   * after navigation.
+   * Browser's navigation and resource timings
    */
-  pageLoaded() {
-    if (this.performanceTiming.loadEventEnd === 0) {
-      console.error('pageLoaded should be called after window.onload');
-      this.async(this.pageLoaded, 100);
-    } else {
-      const perfEvents = Object.keys(this.performanceTiming.toJSON());
-      perfEvents.forEach(
-          eventName => this._reportPerformanceTiming(eventName)
-      );
-    }
+  _reportNavResTimes() {
+    const perfEvents = Object.keys(this.performanceTiming.toJSON());
+    perfEvents.forEach(
+        eventName => this._reportPerformanceTiming(eventName)
+    );
   },
 
   _reportPerformanceTiming(eventName, eventDetails) {
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index ad9903e..bd1584e 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -79,6 +79,12 @@
         element.reporter.calledWithMatch(
             'timing-report', 'UI Latency', 'App Started', 42
         ));
+    assert.isTrue(
+        element.reporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
+            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+            undefined, true)
+    );
   });
 
   test('WebComponentsReady', () => {
@@ -89,16 +95,6 @@
     ));
   });
 
-  test('pageLoaded', () => {
-    element.pageLoaded();
-    assert.isTrue(
-        element.reporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
-            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
-            undefined, true)
-    );
-  });
-
   test('beforeLocationChanged', () => {
     element._baselines['garbage'] = 'monster';
     sandbox.stub(element, 'time');
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index a93c139..b27adf7 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -161,7 +161,7 @@
 
   _mapAccountsHelper(accounts, predicate) {
     return accounts.map(account => {
-      const userName = this.getUserName(this._serverConfig, account, false);
+      const userName = this.getUserName(this._serverConfig, account);
       return {
         label: account.name || '',
         text: account.email ?
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index c425318..0c4b706 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -63,11 +63,12 @@
         type: Boolean,
         notify: true,
         computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
-          '_hasUsernameChange, _hasStatusChange)',
+          '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
       },
 
       _hasNameChange: Boolean,
       _hasUsernameChange: Boolean,
+      _hasDisplayNameChange: Boolean,
       _hasStatusChange: Boolean,
       _loading: {
         type: Boolean,
@@ -95,6 +96,7 @@
     return [
       '_nameChanged(_account.name)',
       '_statusChanged(_account.status)',
+      '_displayNameChanged(_account.display_name)',
     ];
   }
 
@@ -110,6 +112,7 @@
     promises.push(this.$.restAPI.getAccount().then(account => {
       this._hasNameChange = false;
       this._hasUsernameChange = false;
+      this._hasDisplayNameChange = false;
       this._hasStatusChange = false;
       // Provide predefined value for username to trigger computation of
       // username mutability.
@@ -136,10 +139,12 @@
     // Set only the fields that have changed.
     // Must be done in sequence to avoid race conditions (@see Issue 5721)
     return this._maybeSetName()
-        .then(this._maybeSetUsername.bind(this))
-        .then(this._maybeSetStatus.bind(this))
+        .then(() => this._maybeSetUsername())
+        .then(() => this._maybeSetDisplayName())
+        .then(() => this._maybeSetStatus())
         .then(() => {
           this._hasNameChange = false;
+          this._hasDisplayNameChange = false;
           this._hasStatusChange = false;
           this._saving = false;
           this.fire('account-detail-update');
@@ -158,14 +163,22 @@
       Promise.resolve();
   }
 
+  _maybeSetDisplayName() {
+    return this._hasDisplayNameChange ?
+      this.$.restAPI.setAccountDisplayName(this._account.display_name) :
+      Promise.resolve();
+  }
+
   _maybeSetStatus() {
     return this._hasStatusChange ?
       this.$.restAPI.setAccountStatus(this._account.status) :
       Promise.resolve();
   }
 
-  _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
-    return nameChanged || usernameChanged || statusChanged;
+  _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged,
+      displayNameChanged) {
+    return nameChanged || usernameChanged || statusChanged
+        || displayNameChanged;
   }
 
   _computeUsernameMutable(config, username) {
@@ -191,6 +204,11 @@
     this._hasStatusChange = true;
   }
 
+  _displayNameChanged() {
+    if (this._loading) { return; }
+    this._hasDisplayNameChange = true;
+  }
+
   _usernameChanged() {
     if (this._loading || !this._account) { return; }
     this._hasUsernameChange =
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
index 6e37c25..0f5ddc8 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
@@ -79,6 +79,14 @@
         </span>
       </section>
       <section>
+        <span class="title">Display name (defaults to "Full name")</span>
+        <span class="value">
+          <iron-input on-keydown="_handleKeydown" bind-value="{{_account.display_name}}">
+            <input is="iron-input" id="displayNameInput" disabled="[[_saving]]" on-keydown="_handleKeydown" bind-value="{{_account.display_name}}">
+          </iron-input>
+        </span>
+      </section>
+      <section>
         <span class="title">Status (e.g. "Vacation")</span>
         <span class="value">
           <iron-input on-keydown="_handleKeydown" bind-value="{{_account.status}}">
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 4ac540d..22fd1c20 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
@@ -56,7 +56,7 @@
   static get properties() {
     return {
       account: Object,
-      additionalText: String,
+      voteableText: String,
       disabled: {
         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 7f219e5..14bbd57 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
@@ -81,7 +81,7 @@
       }
     </style>
     <div class\$="container [[_getBackgroundClass(transparentBackground)]]">
-      <gr-account-link account="[[account]]" additional-text="[[additionalText]]">
+      <gr-account-link account="[[account]]" voteable-text="[[voteableText]]">
       </gr-account-link>
       <gr-button id="remove" link="" hidden\$="[[!removable]]" hidden="" tabindex="-1" aria-label="Remove" class\$="remove [[_getBackgroundClass(transparentBackground)]]" on-click="_handleRemoveTap">
         <iron-icon icon="gr-icons:close"></iron-icon>
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 ba65e03..d279563 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
@@ -14,13 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
-
-import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 import '../../../styles/shared-styles.js';
 import '../gr-avatar/gr-avatar.js';
-import '../gr-limited-text/gr-limited-text.js';
+import '../gr-hovercard-account/gr-hovercard-account.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../scripts/util.js';
 import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
@@ -31,12 +32,10 @@
 
 /**
  * @appliesMixin Gerrit.DisplayNameMixin
- * @appliesMixin Gerrit.TooltipMixin
  * @extends Polymer.Element
  */
 class GrAccountLabel extends mixinBehaviors( [
   Gerrit.DisplayNameBehavior,
-  Gerrit.TooltipBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
@@ -46,25 +45,11 @@
 
   static get properties() {
     return {
-    /**
-     * @type {{ name: string, status: string }}
-     */
+      /**
+       * @type {{ name: string, status: string }}
+       */
       account: Object,
-      avatarImageSize: {
-        type: Number,
-        value: 32,
-      },
-      title: {
-        type: String,
-        reflectToAttribute: true,
-        computed: '_computeAccountTitle(account, additionalText)',
-      },
-      additionalText: String,
-      hasTooltip: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: '_computeHasTooltip(account)',
-      },
+      voteableText: String,
       hideAvatar: {
         type: Boolean,
         value: false,
@@ -79,68 +64,12 @@
   /** @override */
   ready() {
     super.ready();
-    if (!this.additionalText) { this.additionalText = ''; }
     this.$.restAPI.getConfig()
         .then(config => { this._serverConfig = config; });
   }
 
   _computeName(account, config) {
-    return this.getUserName(config, account, false);
-  }
-
-  _computeStatusTextLength(account, config) {
-    // 35 as the max length of the name + status
-    return Math.max(10, 35 - this._computeName(account, config).length);
-  }
-
-  _computeAccountTitle(account, tooltip) {
-    // Polymer 2: check for undefined
-    if ([
-      account,
-      tooltip,
-    ].some(arg => arg === undefined)) {
-      return undefined;
-    }
-
-    if (!account) { return; }
-    let result = '';
-    if (this._computeName(account, this._serverConfig)) {
-      result += this._computeName(account, this._serverConfig);
-    }
-    if (account.email) {
-      result += ` <${account.email}>`;
-    }
-    if (this.additionalText) {
-      result += ` ${this.additionalText}`;
-    }
-
-    // Show status in the label tooltip instead of
-    // in a separate tooltip on status
-    if (account.status) {
-      result += ` (${account.status})`;
-    }
-
-    return result;
-  }
-
-  _computeShowEmailClass(account) {
-    if (!account || account.name || !account.email) { return ''; }
-    return 'showEmail';
-  }
-
-  _computeEmailStr(account) {
-    if (!account || !account.email) {
-      return '';
-    }
-    if (account.name) {
-      return '(' + account.email + ')';
-    }
-    return account.email;
-  }
-
-  _computeHasTooltip(account) {
-    // If an account has loaded to fire this method, then set to true.
-    return !!account;
+    return this.getUserName(config, account);
   }
 }
 
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 e9d0e5d..a7d01ae 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
@@ -35,27 +35,24 @@
       .text:hover {
         @apply --gr-account-label-text-hover-style;
       }
-      .email,
-      .showEmail .name {
-        display: none;
-      }
-      .showEmail .email {
-        display: inline-block;
+      iron-icon {
+        width: 14px;
+        height: 14px;
+        vertical-align: top;
+        position: relative;
+        top: 2px;
       }
     </style>
     <span>
+      <gr-hovercard-account account="[[account]]" voteable-text="[[voteableText]]"></gr-hovercard-account>
       <template is="dom-if" if="[[!hideAvatar]]">
-        <gr-avatar account="[[account]]" image-size="[[avatarImageSize]]"></gr-avatar>
+        <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
       </template>
-      <span class\$="text [[_computeShowEmailClass(account)]]">
+      <span class="text">
         <span class="name">
           [[_computeName(account, _serverConfig)]]</span>
-        <span class="email">
-          [[_computeEmailStr(account)]]
-        </span>
         <template is="dom-if" if="[[account.status]]">
-          (<gr-limited-text disable-tooltip="true" limit="[[_computeStatusTextLength(account, _serverConfig)]]" text="[[account.status]]">
-          </gr-limited-text>)
+          <iron-icon icon="gr-icons:calendar"></iron-icon>
         </template>
       </span>
     </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index db742e6..fd16350 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -70,54 +70,6 @@
     });
   });
 
-  test('missing email', () => {
-    assert.equal('', element._computeEmailStr({name: 'foo'}));
-  });
-
-  test('computed fields', () => {
-    assert.equal(
-        element._computeAccountTitle({
-          name: 'Andrew Bonventre',
-          email: 'andybons+gerrit@gmail.com',
-        }, /* additionalText= */ ''),
-        'Andrew Bonventre <andybons+gerrit@gmail.com>');
-
-    assert.equal(
-        element._computeAccountTitle({
-          name: 'Andrew Bonventre',
-        }, /* additionalText= */ ''),
-        'Andrew Bonventre');
-
-    assert.equal(
-        element._computeAccountTitle({
-          email: 'andybons+gerrit@gmail.com',
-        }, /* additionalText= */ ''),
-        'Anonymous <andybons+gerrit@gmail.com>');
-
-    assert.equal(element._computeShowEmailClass(
-        {
-          name: 'Andrew Bonventre',
-          email: 'andybons+gerrit@gmail.com',
-        }, /* additionalText= */ ''), '');
-
-    assert.equal(element._computeShowEmailClass(
-        {
-          email: 'andybons+gerrit@gmail.com',
-        }, /* additionalText= */ ''), 'showEmail');
-
-    assert.equal(element._computeShowEmailClass(
-        {name: 'Andrew Bonventre'},
-        /* additionalText= */ ''
-    ),
-    '');
-
-    assert.equal(element._computeShowEmailClass(undefined), '');
-
-    assert.equal(
-        element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
-    assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
-  });
-
   suite('_computeName', () => {
     test('not showing anonymous', () => {
       const account = {name: 'Wyatt'};
@@ -152,45 +104,5 @@
           'TestAnon');
     });
   });
-
-  suite('status in tooltip', () => {
-    setup(() => {
-      element = fixture('basic');
-      element.account = {
-        name: 'test',
-        email: 'test@google.com',
-        status: 'OOO until Aug 10th',
-      };
-      element._config = {
-        user: {
-          anonymous_coward_name: 'Anonymous Coward',
-        },
-      };
-    });
-
-    test('tooltip should contain status text', () => {
-      assert.deepEqual(element.title,
-          'test <test@google.com> (OOO until Aug 10th)');
-    });
-
-    test('status text should not have tooltip', () => {
-      flushAsynchronousOperations();
-      assert.deepEqual(element.shadowRoot
-          .querySelector('gr-limited-text').title, '');
-    });
-
-    test('status text should honor the name length and total length', () => {
-      assert.deepEqual(
-          element._computeStatusTextLength(element.account, element._config),
-          31
-      );
-      assert.deepEqual(
-          element._computeStatusTextLength({
-            name: 'a very long long long long name',
-          }, element._config),
-          10
-      );
-    });
-  });
 });
 </script>
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 4a38427..e0d5583 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,12 +41,8 @@
 
   static get properties() {
     return {
-      additionalText: String,
+      voteableText: String,
       account: Object,
-      avatarImageSize: {
-        type: Number,
-        value: 32,
-      },
     };
   }
 
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 4ea343e..4f1ea44 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
@@ -33,7 +33,7 @@
     </style>
     <span>
       <a href\$="[[_computeOwnerLink(account)]]" tabindex="-1">
-        <gr-account-label account="[[account]]" additional-text="[[additionalText]]" avatar-image-size="[[avatarImageSize]]"></gr-account-label>
+        <gr-account-label account="[[account]]" voteable-text="[[voteableText]]"></gr-account-label>
       </a>
     </span>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index cde56df..41d958fd 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -26,6 +26,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-button_html.js';
+import '../../../scripts/util.js';
 
 /**
  * @appliesMixin Gerrit.KeyboardShortcutMixin
@@ -105,19 +106,8 @@
       return;
     }
 
-    let el = this.root;
-    let path = '';
-    while (el = el.parentNode || el.host) {
-      if (el.tagName && el.tagName.startsWith('GR-APP')) {
-        break;
-      }
-      if (el.tagName) {
-        const idString = el.id ? '#' + el.id : '';
-        path = el.tagName + idString + ' ' + path;
-      }
-    }
     this.$.reporting.reportInteraction('button-click',
-        {path: path.trim().toLowerCase()});
+        {path: util.getEventPath(e)});
   }
 
   _disabledChanged(disabled) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index 42f9a5a..f2ce4ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -40,6 +40,14 @@
   </template>
 </test-fixture>
 
+<test-fixture id="nested">
+  <template>
+    <div id="test">
+      <gr-button class="testBtn"></gr-button>
+    </div>
+  </template>
+</test-fixture>
+
 <test-fixture id="tabindex">
   <template>
     <gr-button tabindex="3"></gr-button>
@@ -190,5 +198,36 @@
       });
     }
   });
+
+  suite('reporting', () => {
+    const reportStub = sinon.stub();
+    setup(() => {
+      stub('gr-reporting', {
+        reportInteraction: (...args) => {
+          reportStub(...args);
+        },
+      });
+      reportStub.reset();
+    });
+
+    test('report event after click', () => {
+      MockInteractions.click(element);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'button-click');
+      assert.deepEqual(reportStub.lastCall.args[1], {
+        path: 'html>body>test-fixture#basic>gr-button',
+      });
+    });
+
+    test('report event after click on nested', () => {
+      element = fixture('nested');
+      MockInteractions.click(element.querySelector('gr-button'));
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'button-click');
+      assert.deepEqual(reportStub.lastCall.args[1], {
+        path: 'html>body>test-fixture#nested>div#test>gr-button.testBtn',
+      });
+    });
+  });
 });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
index a30b65a..2b50565 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
@@ -48,7 +48,7 @@
       }
       code {
         display: block;
-        white-space: pre;
+        white-space: pre-wrap;
         color: var(--deemphasized-text-color);
       }
       li {
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
new file mode 100644
index 0000000..0bc9cb7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
@@ -0,0 +1,50 @@
+/**
+ * @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 '../../../scripts/bundled-polymer.js';
+
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../gr-avatar/gr-avatar.js';
+import '../gr-button/gr-button.js';
+import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior.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-hovercard-account_html.js';
+
+/** @extends Polymer.Element */
+class GrHovercardAccount extends GestureEventListeners(
+    hovercardBehaviorMixin(LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-hovercard-account'; }
+
+  static get properties() {
+    return {
+      account: Object,
+      voteableText: String,
+      attention: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+    };
+  }
+}
+
+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
new file mode 100644
index 0000000..0763420
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
@@ -0,0 +1,96 @@
+/**
+ * @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 '../gr-hovercard/gr-hovercard-shared-style.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+    <style include="gr-hovercard-shared-style">
+      .top,
+      .attention,
+      .status,
+      .voteable {
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .top {
+        display: flex;
+        padding-top: var(--spacing-xl);
+        min-width: 300px;
+      }
+      gr-avatar {
+        height: 48px;
+        width: 48px;
+        margin-right: var(--spacing-l);
+      }
+      .title,
+      .email {
+        color: var(--deemphasized-text-color);
+      }
+      .status iron-icon {
+        width: 14px;
+        height: 14px;
+        vertical-align: top;
+        position: relative;
+        top: 2px;
+      }
+      .action {
+        border-top: 1px solid var(--border-color);
+        padding: var(--spacing-s) var(--spacing-l);
+        --gr-button: {
+          padding: var(--spacing-s) 0;
+        };
+      }
+      :host(:not([attention])) .attention {
+        display: none;
+      }
+      .attention {
+        background-color: var(--emphasis-color);
+      }
+      .attention iron-icon {
+        vertical-align: top;
+      }
+    </style>
+    <div id="container" role="tooltip" tabindex="-1">
+      <div class="top">
+        <div class="avatar">
+          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name">[[account.name]]</h3>
+          <div class="email">[[account.email]]</div>
+        </div>
+      </div>
+      <template is="dom-if" if="[[account.status]]">
+        <div class="status">
+          <span class="title">
+            <iron-icon icon="gr-icons:calendar"></iron-icon>
+            Status:
+          </span>
+          <span class="value">[[account.status]]</span>
+        </div>
+      </template>
+      <template is="dom-if" if="[[voteableText]]">
+        <div class="voteable">
+          <span class="title">Voteable:</span>
+          <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>
+    </div>
+`;
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
new file mode 100644
index 0000000..7a5f4c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<!--
+@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.
+-->
+
+<meta name="viewport"
+      content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-hovercard-account</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" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="../../../node_modules/iron-test-helpers/mock-interactions.js" type="module"></script>
+
+<script type="module" src="./gr-hovercard-account.js"></script>
+
+<script type="module">
+  import '../../../test/test-pre-setup.js';
+  import '../../../test/common-test-setup.js';
+  import './gr-hovercard-account.js';
+
+  void (0);
+</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-hovercard-account class="hovered"></gr-hovercard-account>
+  </template>
+</test-fixture>
+
+
+<script type="module">
+  import '../../../test/test-pre-setup.js';
+  import '../../../test/common-test-setup.js';
+  import './gr-hovercard-account.js';
+
+  suite('gr-hovercard-account tests', () => {
+    let element;
+    const ACCOUNT = {
+      email: 'kermit@gmail.com',
+      username: 'kermit',
+      name: 'Kermit The Frog',
+      _account_id: '31415926535',
+    };
+
+    setup(() => {
+      element = fixture('basic');
+      element.account = Object.assign({}, ACCOUNT);
+    });
+
+    test('account name is shown', () => {
+      assert.equal(element.shadowRoot.querySelector('.name').innerText,
+          'Kermit The Frog');
+    });
+
+    test('account status is not shown if the property is not set', () => {
+      assert.isNull(element.shadowRoot.querySelector('.status'));
+    });
+
+    test('account status is displayed', () => {
+      element.account = Object.assign({status: 'OOO'}, ACCOUNT);
+      flushAsynchronousOperations();
+      assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
+          'OOO');
+    });
+
+    test('voteable div is not shown if the property is not set', () => {
+      assert.isNull(element.shadowRoot.querySelector('.voteable'));
+    });
+
+    test('voteable div is displayed', () => {
+      element.voteableText = 'CodeReview: +2';
+      flushAsynchronousOperations();
+      assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
+          element.voteableText);
+    });
+  });
+</script>
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
new file mode 100644
index 0000000..a77f5f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
@@ -0,0 +1,354 @@
+/**
+ * @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 '../../../scripts/bundled-polymer.js';
+
+import '../../../styles/shared-styles.js';
+import '../../../scripts/rootElement.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const HOVER_CLASS = 'hovered';
+
+/**
+ * When the hovercard is positioned diagonally (bottom-left, bottom-right,
+ * top-left, or top-right), we add additional (invisible) padding so that the
+ * area that a user can hover over to access the hovercard is larger.
+ */
+const DIAGONAL_OVERFLOW = 15;
+
+/**
+ * The mixin for gr-hovercard-behavior.
+ *
+ * @example
+ *
+ * // LegacyElementMixin is still needed to support the old lifecycles
+ * // TODO: Replace old life cycles with new ones.
+ *
+ * class YourComponent extends hovercardBehaviorMixin(
+ *  LegacyElementMixin(PolymerElement)
+ * ) {
+ *   static get is() { return ''; }
+ *   static get template() { return html``; }
+ * }
+ *
+ * customElements.define(GrHovercard.is, GrHovercard);
+ *
+ * @see gr-hovercard.js
+ *
+ * // following annotations are required for polylint
+ * @polymer
+ * @mixinFunction
+ */
+export const hovercardBehaviorMixin = superClass => class extends superClass {
+  static get properties() {
+    return {
+      /**
+       * @type {?}
+       */
+      _target: Object,
+
+      /**
+       * Determines whether or not the hovercard is visible.
+       *
+       * @type {boolean}
+       */
+      _isShowing: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * The `id` of the element that the hovercard is anchored to.
+       *
+       * @type {string}
+       */
+      for: {
+        type: String,
+        observer: '_forChanged',
+      },
+
+      /**
+       * The spacing between the top of the hovercard and the element it is
+       * anchored to.
+       *
+       * @type {number}
+       */
+      offset: {
+        type: Number,
+        value: 14,
+      },
+
+      /**
+       * Positions the hovercard to the top, right, bottom, left, bottom-left,
+       * bottom-right, top-left, or top-right of its content.
+       *
+       * @type {string}
+       */
+      position: {
+        type: String,
+        value: 'right',
+      },
+
+      container: Object,
+      /**
+       * ID for the container element.
+       *
+       * @type {string}
+       */
+      containerId: {
+        type: String,
+        value: 'gr-hovercard-container',
+      },
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this._target) { this._target = this.target; }
+    this.listen(this._target, 'mouseenter', 'show');
+    this.listen(this._target, 'focus', 'show');
+    this.listen(this._target, 'mouseleave', 'hide');
+    this.listen(this._target, 'blur', 'hide');
+    this.listen(this._target, 'click', 'hide');
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('mouseleave',
+        e => this.hide(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    // First, check to see if the container has already been created.
+    this.container = Gerrit.getRootElement()
+        .querySelector('#' + this.containerId);
+
+    if (this.container) { return; }
+
+    // If it does not exist, create and initialize the hovercard container.
+    this.container = document.createElement('div');
+    this.container.setAttribute('id', this.containerId);
+    Gerrit.getRootElement().appendChild(this.container);
+  }
+
+  removeListeners() {
+    this.unlisten(this._target, 'mouseenter', 'show');
+    this.unlisten(this._target, 'focus', 'show');
+    this.unlisten(this._target, 'mouseleave', 'hide');
+    this.unlisten(this._target, 'blur', 'hide');
+    this.unlisten(this._target, 'click', 'hide');
+  }
+
+  /**
+   * Returns the target element that the hovercard is anchored to (the `id` of
+   * the `for` property).
+   *
+   * @type {HTMLElement}
+   */
+  get target() {
+    const parentNode = dom(this).parentNode;
+    // If the parentNode is a document fragment, then we need to use the host.
+    const ownerRoot = dom(this).getOwnerRoot();
+    let target;
+    if (this.for) {
+      target = dom(ownerRoot).querySelector('#' + this.for);
+    } else {
+      target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
+        ownerRoot.host :
+        parentNode;
+    }
+    return target;
+  }
+
+  /**
+   * Hides/closes the hovercard. This occurs when the user triggers the
+   * `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)
+   */
+  hide(e) {
+    const targetRect = this._target.getBoundingClientRect();
+    const x = e.clientX;
+    const y = e.clientY;
+    if (x > targetRect.left && x < targetRect.right && y > targetRect.top &&
+        y < targetRect.bottom) {
+      // Sometimes the hovercard itself obscures the mouse pointer, and
+      // that generates a mouseleave event. We don't want to hide the hovercard
+      // in that situation.
+      return;
+    }
+
+    // If the hovercard is already hidden or 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 (!this._isShowing || e.toElement === this ||
+        (e.fromElement === this && e.toElement === this._target)) {
+      return;
+    }
+
+    // Mark that the hovercard is not visible and do not allow focusing
+    this._isShowing = false;
+
+    // Clear styles in preparation for the next time we need to show the card
+    this.classList.remove(HOVER_CLASS);
+
+    // Reset and remove the hovercard from the DOM
+    this.style.cssText = '';
+    this.$.container.setAttribute('tabindex', -1);
+
+    // Remove the hovercard from the container, given that it is still a child
+    // of the container.
+    if (this.container.contains(this)) {
+      this.container.removeChild(this);
+    }
+  }
+
+  /**
+   * Shows/opens the hovercard. This occurs when the user triggers the
+   * `mousenter` event on the hovercard's `target` element.
+   *
+   * @param {Event} e DOM Event (e.g., `mouseenter` event)
+   */
+  show(e) {
+    if (this._isShowing) {
+      return;
+    }
+
+    // Mark that the hovercard is now visible
+    this._isShowing = true;
+    this.setAttribute('tabindex', 0);
+
+    // Add it to the DOM and calculate its position
+    this.container.appendChild(this);
+    this.updatePosition();
+
+    // Trigger the transition
+    this.classList.add(HOVER_CLASS);
+  }
+
+  /**
+   * Updates the hovercard's position based on the `position` attribute
+   * and the current position of the `target` element.
+   *
+   * The hovercard is supposed to stay open if the user hovers over it.
+   * To keep it open when the user moves away from the target, the bounding
+   * rects of the target and hovercard must touch or overlap.
+   *
+   * NOTE: You do not need to directly call this method unless you need to
+   * update the position of the tooltip while it is already visible (the
+   * target element has moved and the tooltip is still open).
+   */
+  updatePosition() {
+    if (!this._target) { return; }
+
+    // Calculate the necessary measurements and positions
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = this._target.getBoundingClientRect();
+    const thisRect = this.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    let hovercardLeft;
+    let hovercardTop;
+    const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
+    let cssText = '';
+
+    // Find the top and left position values based on the position attribute
+    // of the hovercard.
+    switch (this.position) {
+      case 'top':
+        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+        hovercardTop = targetTop - thisRect.height - this.offset;
+        cssText += `padding-bottom:${this.offset
+        }px; margin-bottom:-${this.offset}px;`;
+        break;
+      case 'bottom':
+        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+        hovercardTop = targetTop + targetRect.height + this.offset;
+        cssText +=
+            `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
+        break;
+      case 'left':
+        hovercardLeft = targetLeft - thisRect.width - this.offset;
+        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+        cssText +=
+            `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
+        break;
+      case 'right':
+        hovercardLeft = targetRect.right + this.offset;
+        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+        cssText +=
+            `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
+        break;
+      case 'bottom-right':
+        hovercardLeft = targetRect.left + targetRect.width + this.offset;
+        hovercardTop = targetRect.top + targetRect.height + this.offset;
+        cssText += `padding-top:${diagonalPadding}px;`;
+        cssText += `padding-left:${diagonalPadding}px;`;
+        cssText += `margin-left:-${diagonalPadding}px;`;
+        cssText += `margin-top:-${diagonalPadding}px;`;
+        break;
+      case 'bottom-left':
+        hovercardLeft = targetRect.left - thisRect.width - this.offset;
+        hovercardTop = targetRect.top + targetRect.height + this.offset;
+        cssText += `padding-top:${diagonalPadding}px;`;
+        cssText += `padding-right:${diagonalPadding}px;`;
+        cssText += `margin-right:-${diagonalPadding}px;`;
+        cssText += `margin-top:-${diagonalPadding}px;`;
+        break;
+      case 'top-left':
+        hovercardLeft = targetRect.left - thisRect.width - this.offset;
+        hovercardTop = targetRect.top - thisRect.height - this.offset;
+        cssText += `padding-bottom:${diagonalPadding}px;`;
+        cssText += `padding-right:${diagonalPadding}px;`;
+        cssText += `margin-bottom:-${diagonalPadding}px;`;
+        cssText += `margin-right:-${diagonalPadding}px;`;
+        break;
+      case 'top-right':
+        hovercardLeft = targetRect.left + targetRect.width + this.offset;
+        hovercardTop = targetRect.top - thisRect.height - this.offset;
+        cssText += `padding-bottom:${diagonalPadding}px;`;
+        cssText += `padding-left:${diagonalPadding}px;`;
+        cssText += `margin-bottom:-${diagonalPadding}px;`;
+        cssText += `margin-left:-${diagonalPadding}px;`;
+        break;
+    }
+
+    // Prevent hovercard from appearing outside the viewport.
+    // TODO(kaspern): fix hovercard appearing outside viewport on bottom and
+    // right.
+    if (hovercardLeft < 0) { hovercardLeft = 0; }
+    if (hovercardTop < 0) { hovercardTop = 0; }
+    // Set the hovercard's position
+    cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
+    this.style.cssText = cssText;
+  }
+
+  /**
+   * Responds to a change in the `for` value and gets the updated `target`
+   * element for the hovercard.
+   *
+   * @private
+   */
+  _forChanged() {
+    this._target = this.target;
+  }
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
new file mode 100644
index 0000000..bb81cfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
@@ -0,0 +1,45 @@
+/**
+ * @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.
+ */
+
+/** The shared styles for all hover cards. */
+const GrHoverCardSharedStyle = document.createElement('dom-module');
+GrHoverCardSharedStyle.innerHTML =
+  `<template>
+    <style include="shared-styles">
+      :host {
+        box-sizing: border-box;
+        opacity: 0;
+        position: absolute;
+        transition: opacity 200ms;
+        visibility: hidden;
+        z-index: 200;
+      }
+      :host(.hovered) {
+        visibility: visible;
+        opacity: 1;
+      }
+      /* You have to use a <div class="container"> in your hovercard in order
+         to pick up this consistent styling. */
+      #container {
+        background: var(--dialog-background-color);
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
+      }
+    </style>
+  </template>`;
+
+GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
index ce2303f..3f936dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -18,327 +18,20 @@
 
 import '../../../styles/shared-styles.js';
 import '../../../scripts/rootElement.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';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-hovercard_html.js';
-
-const HOVER_CLASS = 'hovered';
-
-/**
- * When the hovercard is positioned diagonally (bottom-left, bottom-right,
- * top-left, or top-right), we add additional (invisible) padding so that the
- * area that a user can hover over to access the hovercard is larger.
- */
-const DIAGONAL_OVERFLOW = 15;
+import {hovercardBehaviorMixin} from './gr-hovercard-behavior.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import './gr-hovercard-shared-style.js';
 
 /** @extends Polymer.Element */
 class GrHovercard extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
+    hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-hovercard'; }
-
-  static get properties() {
-    return {
-    /**
-     * @type {?}
-     */
-      _target: Object,
-
-      /**
-       * Determines whether or not the hovercard is visible.
-       *
-       * @type {boolean}
-       */
-      _isShowing: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * The `id` of the element that the hovercard is anchored to.
-       *
-       * @type {string}
-       */
-      for: {
-        type: String,
-        observer: '_forChanged',
-      },
-
-      /**
-       * The spacing between the top of the hovercard and the element it is
-       * anchored to.
-       *
-       * @type {number}
-       */
-      offset: {
-        type: Number,
-        value: 14,
-      },
-
-      /**
-       * Positions the hovercard to the top, right, bottom, left, bottom-left,
-       * bottom-right, top-left, or top-right of its content.
-       *
-       * @type {string}
-       */
-      position: {
-        type: String,
-        value: 'bottom',
-      },
-
-      container: Object,
-      /**
-       * ID for the container element.
-       *
-       * @type {string}
-       */
-      containerId: {
-        type: String,
-        value: 'gr-hovercard-container',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    if (!this._target) { this._target = this.target; }
-    this.listen(this._target, 'mouseenter', 'show');
-    this.listen(this._target, 'focus', 'show');
-    this.listen(this._target, 'mouseleave', 'hide');
-    this.listen(this._target, 'blur', 'hide');
-    this.listen(this._target, 'click', 'hide');
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('mouseleave',
-        e => this.hide(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    // First, check to see if the container has already been created.
-    this.container = Gerrit.getRootElement()
-        .querySelector('#' + this.containerId);
-
-    if (this.container) { return; }
-
-    // If it does not exist, create and initialize the hovercard container.
-    this.container = document.createElement('div');
-    this.container.setAttribute('id', this.containerId);
-    Gerrit.getRootElement().appendChild(this.container);
-  }
-
-  removeListeners() {
-    this.unlisten(this._target, 'mouseenter', 'show');
-    this.unlisten(this._target, 'focus', 'show');
-    this.unlisten(this._target, 'mouseleave', 'hide');
-    this.unlisten(this._target, 'blur', 'hide');
-    this.unlisten(this._target, 'click', 'hide');
-  }
-
-  /**
-   * Returns the target element that the hovercard is anchored to (the `id` of
-   * the `for` property).
-   *
-   * @type {HTMLElement}
-   */
-  get target() {
-    const parentNode = dom(this).parentNode;
-    // If the parentNode is a document fragment, then we need to use the host.
-    const ownerRoot = dom(this).getOwnerRoot();
-    let target;
-    if (this.for) {
-      target = dom(ownerRoot).querySelector('#' + this.for);
-    } else {
-      target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
-        ownerRoot.host :
-        parentNode;
-    }
-    return target;
-  }
-
-  /**
-   * Hides/closes the hovercard. This occurs when the user triggers the
-   * `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)
-   */
-  hide(e) {
-    const targetRect = this._target.getBoundingClientRect();
-    const x = e.clientX;
-    const y = e.clientY;
-    if (x > targetRect.left && x < targetRect.right && y > targetRect.top &&
-        y < targetRect.bottom) {
-      // Sometimes the hovercard itself obscures the mouse pointer, and
-      // that generates a mouseleave event. We don't want to hide the hovercard
-      // in that situation.
-      return;
-    }
-
-    // If the hovercard is already hidden or 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 (!this._isShowing || e.toElement === this ||
-        (e.fromElement === this && e.toElement === this._target)) {
-      return;
-    }
-
-    // Mark that the hovercard is not visible and do not allow focusing
-    this._isShowing = false;
-
-    // Clear styles in preparation for the next time we need to show the card
-    this.classList.remove(HOVER_CLASS);
-
-    // Reset and remove the hovercard from the DOM
-    this.style.cssText = '';
-    this.$.hovercard.setAttribute('tabindex', -1);
-
-    // Remove the hovercard from the container, given that it is still a child
-    // of the container.
-    if (this.container.contains(this)) {
-      this.container.removeChild(this);
-    }
-  }
-
-  /**
-   * Shows/opens the hovercard. This occurs when the user triggers the
-   * `mousenter` event on the hovercard's `target` element.
-   *
-   * @param {Event} e DOM Event (e.g., `mouseenter` event)
-   */
-  show(e) {
-    if (this._isShowing) {
-      return;
-    }
-
-    // Mark that the hovercard is now visible
-    this._isShowing = true;
-    this.setAttribute('tabindex', 0);
-
-    // Add it to the DOM and calculate its position
-    this.container.appendChild(this);
-    this.updatePosition();
-
-    // Trigger the transition
-    this.classList.add(HOVER_CLASS);
-  }
-
-  /**
-   * Updates the hovercard's position based on the `position` attribute
-   * and the current position of the `target` element.
-   *
-   * The hovercard is supposed to stay open if the user hovers over it.
-   * To keep it open when the user moves away from the target, the bounding
-   * rects of the target and hovercard must touch or overlap.
-   *
-   * NOTE: You do not need to directly call this method unless you need to
-   * update the position of the tooltip while it is already visible (the
-   * target element has moved and the tooltip is still open).
-   */
-  updatePosition() {
-    if (!this._target) { return; }
-
-    // Calculate the necessary measurements and positions
-    const parentRect = document.documentElement.getBoundingClientRect();
-    const targetRect = this._target.getBoundingClientRect();
-    const thisRect = this.getBoundingClientRect();
-
-    const targetLeft = targetRect.left - parentRect.left;
-    const targetTop = targetRect.top - parentRect.top;
-
-    let hovercardLeft;
-    let hovercardTop;
-    const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
-    let cssText = '';
-
-    // Find the top and left position values based on the position attribute
-    // of the hovercard.
-    switch (this.position) {
-      case 'top':
-        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
-        hovercardTop = targetTop - thisRect.height - this.offset;
-        cssText += `padding-bottom:${this.offset
-        }px; margin-bottom:-${this.offset}px;`;
-        break;
-      case 'bottom':
-        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
-        hovercardTop = targetTop + targetRect.height + this.offset;
-        cssText +=
-            `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
-        break;
-      case 'left':
-        hovercardLeft = targetLeft - thisRect.width - this.offset;
-        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-        cssText +=
-            `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
-        break;
-      case 'right':
-        hovercardLeft = targetRect.right + this.offset;
-        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-        cssText +=
-            `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
-        break;
-      case 'bottom-right':
-        hovercardLeft = targetRect.left + targetRect.width + this.offset;
-        hovercardTop = targetRect.top + targetRect.height + this.offset;
-        cssText += `padding-top:${diagonalPadding}px;`;
-        cssText += `padding-left:${diagonalPadding}px;`;
-        cssText += `margin-left:-${diagonalPadding}px;`;
-        cssText += `margin-top:-${diagonalPadding}px;`;
-        break;
-      case 'bottom-left':
-        hovercardLeft = targetRect.left - thisRect.width - this.offset;
-        hovercardTop = targetRect.top + targetRect.height + this.offset;
-        cssText += `padding-top:${diagonalPadding}px;`;
-        cssText += `padding-right:${diagonalPadding}px;`;
-        cssText += `margin-right:-${diagonalPadding}px;`;
-        cssText += `margin-top:-${diagonalPadding}px;`;
-        break;
-      case 'top-left':
-        hovercardLeft = targetRect.left - thisRect.width - this.offset;
-        hovercardTop = targetRect.top - thisRect.height - this.offset;
-        cssText += `padding-bottom:${diagonalPadding}px;`;
-        cssText += `padding-right:${diagonalPadding}px;`;
-        cssText += `margin-bottom:-${diagonalPadding}px;`;
-        cssText += `margin-right:-${diagonalPadding}px;`;
-        break;
-      case 'top-right':
-        hovercardLeft = targetRect.left + targetRect.width + this.offset;
-        hovercardTop = targetRect.top - thisRect.height - this.offset;
-        cssText += `padding-bottom:${diagonalPadding}px;`;
-        cssText += `padding-left:${diagonalPadding}px;`;
-        cssText += `margin-bottom:-${diagonalPadding}px;`;
-        cssText += `margin-left:-${diagonalPadding}px;`;
-        break;
-    }
-
-    // Prevent hovercard from appearing outside the viewport.
-    // TODO(kaspern): fix hovercard appearing outside viewport on bottom and
-    // right.
-    if (hovercardLeft < 0) { hovercardLeft = 0; }
-    if (hovercardTop < 0) { hovercardTop = 0; }
-    // Set the hovercard's position
-    cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
-    this.style.cssText = cssText;
-  }
-
-  /**
-   * Responds to a change in the `for` value and gets the updated `target`
-   * element for the hovercard.
-   *
-   * @private
-   */
-  _forChanged() {
-    this._target = this.target;
-  }
 }
 
 customElements.define(GrHovercard.is, GrHovercard);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
index 2969bdb..69fd4c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
@@ -17,26 +17,12 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
 export const htmlTemplate = html`
-    <style include="shared-styles">
-      :host {
-        box-sizing: border-box;
-        opacity: 0;
-        position: absolute;
-        transition: opacity 200ms;
-        visibility: hidden;
-        z-index: 100;
-      }
-      :host(.hovered) {
-        visibility: visible;
-        opacity: 1;
-      }
-      #hovercard {
-        background: var(--dialog-background-color);
-        box-shadow: var(--elevation-level-2);
+    <style include="gr-hovercard-shared-style">
+      #container {
         padding: var(--spacing-l);
       }
     </style>
-    <div id="hovercard" role="tooltip" tabindex="-1">
+    <div id="container" role="tooltip" tabindex="-1">
       <slot></slot>
     </div>
 `;
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 5d7da6c..f7b8b63 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -59,6 +59,8 @@
       <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-->
       <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
+      <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="error"><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-2h2v2zm0-4h-2V7h2v6z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
@@ -94,51 +96,10 @@
       <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
       <!-- 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>
     </defs>
   </svg>
 </iron-iconset-svg>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from material.io https://material.io/icons/#unfold_more */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full*/
-/* This SVG is a copy from material.io https://material.io/icons/#mode_comment*/
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/* This is a custom PolyGerrit SVG */
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
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 3e78bd3..849be00 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
@@ -772,6 +772,23 @@
   }
 
   /**
+   * @param {string} displayName
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountDisplayName(displayName, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/displayname',
+      body: {display_name: displayName},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newName => this._updateCachedAccount({displayName: newName}));
+  }
+
+  /**
    * @param {string} status
    * @param {function(?Response, string=)=} opt_errFn
    */
@@ -1201,15 +1218,7 @@
       patchNum,
       reportEndpointAsIs: true,
     };
-    return this._getChangeURLAndFetch(req).then(revisionActions => {
-      // The rebase button on change screen is always enabled.
-      if (revisionActions.rebase) {
-        revisionActions.rebase.rebaseOnCurrent =
-            !!revisionActions.rebase.enabled;
-        revisionActions.rebase.enabled = true;
-      }
-      return revisionActions;
-    });
+    return this._getChangeURLAndFetch(req);
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 13ed562..72c875f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -332,36 +332,6 @@
         ]);
   });
 
-  suite('rebase action', () => {
-    let resolve_fetchJSON;
-    setup(() => {
-      sandbox.stub(element._restApiHelper, 'fetchJSON').returns(
-          new Promise(resolve => {
-            resolve_fetchJSON = resolve;
-          }));
-    });
-
-    test('no rebase on current', done => {
-      element.getChangeRevisionActions('42', '1337').then(
-          response => {
-            assert.isTrue(response.rebase.enabled);
-            assert.isFalse(response.rebase.rebaseOnCurrent);
-            done();
-          });
-      resolve_fetchJSON({rebase: {}});
-    });
-
-    test('rebase on current', done => {
-      element.getChangeRevisionActions('42', '1337').then(
-          response => {
-            assert.isTrue(response.rebase.enabled);
-            assert.isTrue(response.rebase.rebaseOnCurrent);
-            done();
-          });
-      resolve_fetchJSON({rebase: {enabled: true}});
-    });
-  });
-
   test('server error', done => {
     const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
     window.fetch.returns(Promise.resolve({ok: false}));
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 8307fad..6e1f63a 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,4 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/node_tools/legacy:index.bzl", "polymer_bundler_tool")
 load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
 
 def polygerrit_bundle(name, srcs, outs, entry_point):
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
index f0d0e7f..cefd254 100644
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
@@ -24,16 +24,12 @@
   const ANONYMOUS_NAME = 'Anonymous';
 
   class GrDisplayNameUtils {
-    /**
-     * enableEmail when true enables to fallback to using email if
-     * the account name is not avilable.
-     */
-    static getUserName(config, account, enableEmail) {
+    static getUserName(config, account) {
       if (account && account.name) {
         return account.name;
       } else if (account && account.username) {
         return account.username;
-      } else if (enableEmail && account && account.email) {
+      } else if (account && account.email) {
         return account.email;
       } else if (config && config.user &&
           config.user.anonymous_coward_name !== 'Anonymous Coward') {
@@ -43,8 +39,8 @@
       return ANONYMOUS_NAME;
     }
 
-    static getAccountDisplayName(config, account, enableEmail) {
-      const reviewerName = this.getUserName(config, account, !!enableEmail);
+    static getAccountDisplayName(config, account) {
+      const reviewerName = this.getUserName(config, account);
       const reviewerEmail = this._accountEmail(account.email);
       const reviewerStatus = account.status ? '(' + account.status + ')' : '';
       return [reviewerName, reviewerEmail, reviewerStatus]
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
index b9ddac62..7ef5250 100644
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
@@ -43,7 +43,7 @@
     const account = {
       name: 'test-name',
     };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
         'test-name');
   });
 
@@ -51,7 +51,7 @@
     const account = {
       username: 'test-user',
     };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
         'test-user');
   });
 
@@ -59,12 +59,12 @@
     const account = {
       email: 'test-user@test-url.com',
     };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
         'test-user@test-url.com');
   });
 
   test('getUserName returns not Anonymous Coward as the anon name', () => {
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null),
         'Anonymous');
   });
 
@@ -74,7 +74,7 @@
         anonymous_coward_name: 'Test Anon',
       },
     };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null),
         'Test Anon');
   });
 
@@ -89,13 +89,6 @@
     assert.equal(
         GrDisplayNameUtils.getAccountDisplayName(config,
             {email: 'my@example.com'}),
-        'Anonymous <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with email only - allowEmail', () => {
-    assert.equal(
-        GrDisplayNameUtils.getAccountDisplayName(config,
-            {email: 'my@example.com'}, true),
         'my@example.com <my@example.com>');
   });
 
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
index 67001d2..a1fd94a 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
@@ -36,7 +36,7 @@
 
     makeSuggestionItem(account) {
       return {
-        name: GrDisplayNameUtils.getAccountDisplayName(null, account, true),
+        name: GrDisplayNameUtils.getAccountDisplayName(null, account),
         value: {account, count: 1},
       };
     }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
index fecf75aa..a47eb72 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
@@ -87,7 +87,7 @@
         // Reviewer is an account suggestion from getChangeSuggestedReviewers.
         return {
           name: GrDisplayNameUtils.getAccountDisplayName(this._config,
-              suggestion.account, false),
+              suggestion.account),
           value: suggestion,
         };
       }
@@ -104,7 +104,7 @@
         // Reviewer is an account suggestion from getSuggestedAccounts.
         return {
           name: GrDisplayNameUtils.getAccountDisplayName(this._config,
-              suggestion, false),
+              suggestion),
           value: {account: suggestion, count: 1},
         };
       }
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 6a8a116..e8a1d21 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -170,5 +170,51 @@
     return [...results];
   };
 
+  function getPathFromNode(el) {
+    if (!el.tagName || el.tagName === 'GR-APP'
+      || el instanceof DocumentFragment
+      || el instanceof HTMLSlotElement) {
+      return '';
+    }
+    let path = el.tagName.toLowerCase();
+    if (el.id) path += `#${el.id}`;
+    if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
+    return path;
+  }
+
+  /**
+   * Retrieves the dom path of the current event.
+   *
+   * If the event object contains a `path` property, then use it,
+   * otherwise, construct the dom path based on the event target.
+   *
+   * @param {!Event} e
+   * @return {string}
+   * @example
+   *
+   * domNode.onclick = e => {
+   *  getEventPath(e); // eg: div.class1>p#pid.class2
+   * }
+   */
+  util.getEventPath = e => {
+    if (!e) return '';
+
+    let path = e.path;
+    if (!path || !path.length) {
+      path = [];
+      let el = e.target;
+      while (el) {
+        path.push(el);
+        el = el.parentNode || el.host;
+      }
+    }
+
+    return path.reduce((domPath, curEl) => {
+      const pathForEl = getPathFromNode(curEl);
+      if (!pathForEl) return domPath;
+      return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+    }, '');
+  };
+
   window.util = util;
 })(window);
diff --git a/polygerrit-ui/app/scripts/util_test.html b/polygerrit-ui/app/scripts/util_test.html
new file mode 100644
index 0000000..332707e
--- /dev/null
+++ b/polygerrit-ui/app/scripts/util_test.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<!--
+@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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<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 id="test" class="a b c">
+      <a class="testBtn"></a>
+    </div>
+  </template>
+</test-fixture>
+
+<script type="module">
+  import '../test/common-test-setup.js';
+  import './util.js';
+  suite('util tests', () => {
+    suite('getEventPath', () => {
+      test('empty event', () => {
+        assert.equal(util.getEventPath(), '');
+        assert.equal(util.getEventPath(null), '');
+        assert.equal(util.getEventPath(undefined), '');
+        assert.equal(util.getEventPath({}), '');
+      });
+
+      test('event with fake path', () => {
+        assert.equal(util.getEventPath({path: []}), '');
+        assert.equal(util.getEventPath({path: [
+          {tagName: 'dd'},
+        ]}), 'dd');
+      });
+
+      test('event with fake complicated path', () => {
+        assert.equal(util.getEventPath({path: [
+          {tagName: 'dd', id: 'test', className: 'a b'},
+          {tagName: 'DIV', id: 'test2', className: 'a b c'},
+        ]}), 'div#test2.a.b.c>dd#test.a.b');
+      });
+
+      test('event with fake target', () => {
+        const fakeTargetParent2 = {
+          tagName: 'DIV', id: 'test2', className: 'a b c',
+        };
+        const fakeTargetParent1 = {
+          parentNode: fakeTargetParent2,
+          tagName: 'dd',
+          id: 'test',
+          className: 'a b',
+        };
+        const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
+        assert.equal(
+            util.getEventPath({target: fakeTarget}),
+            'div#test2.a.b.c>dd#test.a.b>span'
+        );
+      });
+
+      test('event with real click', () => {
+        const element = fixture('basic');
+        const aLink = element.querySelector('a');
+        let path;
+        aLink.onclick = e => path = util.getEventPath(e);
+        MockInteractions.click(aLink);
+        assert.equal(
+            path,
+            'html>body>test-fixture#basic>div#test.a.b.c>a.testBtn'
+        );
+      });
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 45e5af6..5b19340 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -189,6 +189,7 @@
     'shared/gr-editable-label/gr-editable-label_test.html',
     'shared/gr-formatted-text/gr-formatted-text_test.html',
     'shared/gr-hovercard/gr-hovercard_test.html',
+    'shared/gr-hovercard-account/gr-hovercard-account_test.html',
     'shared/gr-js-api-interface/gr-annotation-actions-context_test.html',
     'shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
@@ -262,6 +263,7 @@
     'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
     'gr-display-name-utils/gr-display-name-utils_test.html',
     'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
+    'util_test.html',
   ];
   /* eslint-enable max-len */
   for (let file of scripts) {
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index fde2f42..339812b 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -115,16 +115,16 @@
 		return
 	}
 
-	requestPath := parsedUrl.Path
+	normalizedContentPath := parsedUrl.Path
 
-	if !strings.HasPrefix(requestPath, "/") {
-		requestPath = "/" + requestPath
+	if !strings.HasPrefix(normalizedContentPath, "/") {
+		normalizedContentPath = "/" + normalizedContentPath
 	}
 
-	isJsFile := strings.HasSuffix(requestPath, ".js") || strings.HasSuffix(requestPath, ".mjs")
-	data, err := readFile(parsedUrl.Path, requestPath)
+	isJsFile := strings.HasSuffix(normalizedContentPath, ".js") || strings.HasSuffix(normalizedContentPath, ".mjs")
+	data, err := getContent(normalizedContentPath)
 	if err != nil {
-		data, err = readFile(parsedUrl.Path + ".js", requestPath + ".js")
+		data, err = getContent(normalizedContentPath + ".js")
 		if err != nil {
 			writer.WriteHeader(404)
 			return
@@ -135,13 +135,13 @@
 		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
 		writer.Header().Set("Content-Type", "application/javascript")
-	} else if strings.HasSuffix(requestPath, ".css") {
+	} else if strings.HasSuffix(normalizedContentPath, ".css") {
 		writer.Header().Set("Content-Type", "text/css")
-	} else if strings.HasSuffix(requestPath, "_test.html") {
+	} else if strings.HasSuffix(normalizedContentPath, "_test.html") {
 		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
 		writer.Header().Set("Content-Type", "text/html")
-	} else if strings.HasSuffix(requestPath, ".html") {
+	} else if strings.HasSuffix(normalizedContentPath, ".html") {
 		writer.Header().Set("Content-Type", "text/html")
 	}
 	writer.WriteHeader(200)
@@ -149,23 +149,24 @@
 	writer.Write(data)
 }
 
-func readFile(originalPath string, redirectedPath string) ([]byte, error) {
-	pathsToTry := []string{"app" + redirectedPath}
+func getContent(normalizedContentPath string) ([]byte, error) {
+  //normalizedContentPath must always starts with '/'
+	pathsToTry := []string{"app" + normalizedContentPath}
 	bowerComponentsSuffix := "/bower_components/"
 	nodeModulesPrefix := "/node_modules/"
 	testComponentsPrefix := "/components/"
 
-	if strings.HasPrefix(originalPath, testComponentsPrefix) {
-		pathsToTry = append(pathsToTry, "node_modules/wct-browser-legacy/node_modules/"+originalPath[len(testComponentsPrefix):])
-		pathsToTry = append(pathsToTry, "node_modules/"+originalPath[len(testComponentsPrefix):])
+	if strings.HasPrefix(normalizedContentPath, testComponentsPrefix) {
+		pathsToTry = append(pathsToTry, "node_modules/wct-browser-legacy/node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
+		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
 	}
 
-	if strings.HasPrefix(originalPath, bowerComponentsSuffix) {
-		pathsToTry = append(pathsToTry, "node_modules/@webcomponents/"+originalPath[len(bowerComponentsSuffix):])
+	if strings.HasPrefix(normalizedContentPath, bowerComponentsSuffix) {
+		pathsToTry = append(pathsToTry, "node_modules/@webcomponents/"+normalizedContentPath[len(bowerComponentsSuffix):])
 	}
 
-	if strings.HasPrefix(originalPath, nodeModulesPrefix) {
-		pathsToTry = append(pathsToTry, "node_modules/"+originalPath[len(nodeModulesPrefix):])
+	if strings.HasPrefix(normalizedContentPath, nodeModulesPrefix) {
+		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(nodeModulesPrefix):])
 	}
 
 	for _, path := range pathsToTry {