Merge "Change page load report to report all navigation and resource timings"
diff --git a/Documentation/dev-release-jgit.txt b/Documentation/dev-release-jgit.txt
deleted file mode 100644
index 7fbbb95..0000000
--- a/Documentation/dev-release-jgit.txt
+++ /dev/null
@@ -1,53 +0,0 @@
-:linkattrs:
-= Making a Snapshot Release of JGit
-
-This step is only necessary if we need to create an unofficial JGit
-snapshot release and publish it to the
-link:https://developers.google.com/storage/[Google Cloud Storage,role=external,window=_blank].
-
-[[prepare-environment]]
-== Prepare the Maven Environment
-
-First, make sure you have done the necessary
-link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
-configuration in Maven `settings.xml`].
-
-To apply the necessary settings in JGit's `pom.xml`, follow the instructions
-in link:dev-release-deploy-config.html#deploy-configuration-subprojects[
-Configuration for Subprojects in `pom.xml`], or apply the provided diff by
-executing the following command in the JGit workspace:
-
-----
-  git apply /path/to/gerrit/tools/jgit-snapshot-deploy-pom.diff
-----
-
-[[prepare-release]]
-== Prepare the Release
-
-Since JGit has its own release process we do not push any release tags. Instead
-we will use the output of `git describe` as the version of the current JGit
-snapshot.
-
-In the JGit workspace, execute the following command:
-
-----
-  ./tools/version.sh --release $(git describe)
-----
-
-[[publish-release]]
-== Publish the Release
-
-To deploy the new snapshot, execute the following command in the JGit
-workspace:
-
-----
-  mvn deploy
-----
-
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 60127b9..7a84501 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -40,6 +40,7 @@
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
@@ -61,6 +62,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
@@ -836,6 +838,10 @@
     return gApi.changes().id(id).info();
   }
 
+  protected ChangeApi change(Result r) throws RestApiException {
+    return gApi.changes().id(r.getChange().getId().get());
+  }
+
   protected Optional<EditInfo> getEdit(String id) throws RestApiException {
     return gApi.changes().id(id).edit().get();
   }
diff --git a/java/com/google/gerrit/entities/AttentionStatus.java b/java/com/google/gerrit/entities/AttentionSetUpdate.java
similarity index 77%
rename from java/com/google/gerrit/entities/AttentionStatus.java
rename to java/com/google/gerrit/entities/AttentionSetUpdate.java
index c488ccd..45588722 100644
--- a/java/com/google/gerrit/entities/AttentionStatus.java
+++ b/java/com/google/gerrit/entities/AttentionSetUpdate.java
@@ -21,14 +21,14 @@
 /**
  * A single update to the attention set. To reconstruct the attention set these instances are parsed
  * in reverse chronological order. Since each update contains all required information and
- * invalidates all previous state (hence the name -Status rather than -Update), only the most recent
- * record is relevant for each user.
+ * invalidates all previous state, only the most recent record is relevant for each user.
  *
- * <p>See <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">here</a> for
- * details.
+ * <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 AttentionStatus {
+public abstract class AttentionSetUpdate {
 
   /** Users can be added to or removed from the attention set. */
   public enum Operation {
@@ -56,17 +56,17 @@
    * Create an instance from data read from NoteDB. This includes the timestamp taken from the
    * commit.
    */
-  public static AttentionStatus createFromRead(
+  public static AttentionSetUpdate createFromRead(
       Instant timestamp, Account.Id account, Operation operation, String reason) {
-    return new AutoValue_AttentionStatus(timestamp, account, operation, reason);
+    return new AutoValue_AttentionSetUpdate(timestamp, account, operation, reason);
   }
 
   /**
    * Create an instance to be written to NoteDB. This has no timestamp because the timestamp of the
    * commit will be used.
    */
-  public static AttentionStatus createForWrite(
+  public static AttentionSetUpdate createForWrite(
       Account.Id account, Operation operation, String reason) {
-    return new AutoValue_AttentionStatus(null, account, operation, reason);
+    return new AutoValue_AttentionSetUpdate(null, account, operation, reason);
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
new file mode 100644
index 0000000..39efc64
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
@@ -0,0 +1,33 @@
+// 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.extensions.api.changes;
+
+/**
+ * Input at API level to add a user to the attention set.
+ *
+ * @see RemoveFromAttentionSetInput
+ * @see com.google.gerrit.extensions.common.AttentionSetEntry
+ */
+public class AddToAttentionSetInput {
+  public String user;
+  public String reason;
+
+  public AddToAttentionSetInput(String user, String reason) {
+    this.user = user;
+    this.reason = reason;
+  }
+
+  public AddToAttentionSetInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
new file mode 100644
index 0000000..5086cd8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
@@ -0,0 +1,35 @@
+// 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.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/** API for managing the attention set of a change. */
+public interface AttentionSetApi {
+
+  void remove(RemoveFromAttentionSetInput input) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements AttentionSetApi {
+    @Override
+    public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 96455a6..284d8f6 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -312,6 +312,16 @@
    */
   Set<String> getHashtags() throws RestApiException;
 
+  /**
+   * Manage the attention set.
+   *
+   * @param id The account identifier.
+   */
+  AttentionSetApi attention(String id) throws RestApiException;
+
+  /** Adds a user to the attention set. */
+  AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException;
+
   /** Set the assignee of a change. */
   AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
 
@@ -581,6 +591,16 @@
     }
 
     @Override
+    public AttentionSetApi attention(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
new file mode 100644
index 0000000..9212788
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
@@ -0,0 +1,33 @@
+// 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.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/**
+ * Input at API level to remove a user from the attention set.
+ *
+ * @see AddToAttentionSetInput
+ * @see com.google.gerrit.extensions.common.AttentionSetEntry
+ */
+public class RemoveFromAttentionSetInput {
+  @DefaultInput public String reason;
+
+  public RemoveFromAttentionSetInput(String reason) {
+    this.reason = reason;
+  }
+
+  public RemoveFromAttentionSetInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetEntry.java b/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
new file mode 100644
index 0000000..356b38a
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
@@ -0,0 +1,39 @@
+// 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.extensions.common;
+
+import java.sql.Timestamp;
+
+/**
+ * Represents a single user included in the attention set. Used in the API. See {@link
+ * com.google.gerrit.entities.AttentionSetUpdate} for the internal representation.
+ *
+ * <p>See <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">here</a> for
+ * background.
+ */
+public class AttentionSetEntry {
+  /** The user included in the attention set. */
+  public AccountInfo accountInfo;
+  /** The timestamp of the last update. */
+  public Timestamp lastUpdate;
+  /** The human readable reason why the user was added. */
+  public String reason;
+
+  public AttentionSetEntry(AccountInfo accountInfo, Timestamp lastUpdate, String reason) {
+    this.accountInfo = accountInfo;
+    this.lastUpdate = lastUpdate;
+    this.reason = reason;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index d4a8477..dce6fd1 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -22,13 +22,28 @@
 import java.util.List;
 import java.util.Map;
 
+/**
+ * Representation of a change used in the API. Internally {@link
+ * com.google.gerrit.server.query.change.ChangeData} and {@link com.google.gerrit.entities.Change}
+ * are used.
+ *
+ * <p>Many fields are actually nullable.
+ */
 public class ChangeInfo {
   // ActionJson#copy(List, ChangeInfo) must be adapted if new fields are added that are not
   // protected by any ListChangesOption.
+
   public String id;
   public String project;
   public String branch;
   public String topic;
+  /**
+   * The <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">attention set</a>
+   * for this change. Keyed by account ID. We don't use {@link
+   * com.google.gerrit.entities.Account.Id} to avoid a circular dependency.
+   */
+  public Map<Integer, AttentionSetEntry> attentionSet;
+
   public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 5f00b69..dd48b93 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -49,6 +49,8 @@
   public static final String TAG_RESTORE = AUTOGENERATED_TAG_PREFIX + "gerrit:restore";
   public static final String TAG_REVERT = AUTOGENERATED_TAG_PREFIX + "gerrit:revert";
   public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_TAG_PREFIX + "gerrit:setAssignee";
+  public static final String TAG_UPDATE_ATTENTION_SET =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:updateAttentionSet";
   public static final String TAG_SET_DESCRIPTION =
       AUTOGENERATED_TAG_PREFIX + "gerrit:setPsDescription";
   public static final String TAG_SET_HASHTAGS = AUTOGENERATED_TAG_PREFIX + "gerrit:setHashtag";
diff --git a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
new file mode 100644
index 0000000..8dc44b7
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.AttentionSetApi;
+import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.restapi.change.RemoveFromAttentionSet;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class AttentionSetApiImpl implements AttentionSetApi {
+  interface Factory {
+    AttentionSetApiImpl create(AttentionSetEntryResource attentionSetEntryResource);
+  }
+
+  private final RemoveFromAttentionSet removeFromAttentionSet;
+  private final AttentionSetEntryResource attentionSetEntryResource;
+
+  @Inject
+  AttentionSetApiImpl(
+      RemoveFromAttentionSet removeFromAttentionSet,
+      @Assisted AttentionSetEntryResource attentionSetEntryResource) {
+    this.removeFromAttentionSet = removeFromAttentionSet;
+    this.attentionSetEntryResource = attentionSetEntryResource;
+  }
+
+  @Override
+  public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+    try {
+      removeFromAttentionSet.apply(attentionSetEntryResource, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove from attention set", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0d640d9..5122f8a 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -23,7 +23,9 @@
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
@@ -66,6 +68,8 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
+import com.google.gerrit.server.restapi.change.AddToAttentionSet;
+import com.google.gerrit.server.restapi.change.AttentionSet;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
 import com.google.gerrit.server.restapi.change.Check;
@@ -147,6 +151,9 @@
   private final Provider<GetChange> getChangeProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
+  private final AttentionSet attentionSet;
+  private final AttentionSetApiImpl.Factory attentionSetApi;
+  private final AddToAttentionSet addToAttentionSet;
   private final PutAssignee putAssignee;
   private final GetAssignee getAssignee;
   private final GetPastAssignees getPastAssignees;
@@ -197,6 +204,9 @@
       Provider<GetChange> getChangeProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
+      AttentionSet attentionSet,
+      AttentionSetApiImpl.Factory attentionSetApi,
+      AddToAttentionSet addToAttentionSet,
       PutAssignee putAssignee,
       GetAssignee getAssignee,
       GetPastAssignees getPastAssignees,
@@ -245,6 +255,9 @@
     this.getChangeProvider = getChangeProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
+    this.attentionSet = attentionSet;
+    this.attentionSetApi = attentionSetApi;
+    this.addToAttentionSet = addToAttentionSet;
     this.putAssignee = putAssignee;
     this.getAssignee = getAssignee;
     this.getPastAssignees = getPastAssignees;
@@ -530,6 +543,24 @@
   }
 
   @Override
+  public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+    try {
+      return addToAttentionSet.apply(change, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add to attention set", e);
+    }
+  }
+
+  @Override
+  public AttentionSetApi attention(String id) throws RestApiException {
+    try {
+      return attentionSetApi.create(attentionSet.parse(change, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse account", e);
+    }
+  }
+
+  @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
       return putAssignee.apply(change, input).value();
diff --git a/java/com/google/gerrit/server/api/changes/Module.java b/java/com/google/gerrit/server/api/changes/Module.java
index 0edd58a..f54d1fe 100644
--- a/java/com/google/gerrit/server/api/changes/Module.java
+++ b/java/com/google/gerrit/server/api/changes/Module.java
@@ -32,5 +32,6 @@
     factory(RevisionReviewerApiImpl.Factory.class);
     factory(ChangeEditApiImpl.Factory.class);
     factory(ChangeMessageApiImpl.Factory.class);
+    factory(AttentionSetApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index e87cf70..6f28dad 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -89,14 +89,12 @@
     return Lists.newArrayList(visitorSet);
   }
 
-  public ChangeInfo addChangeActions(ChangeInfo to, ChangeNotes notes) {
+  void addChangeActions(ChangeInfo to, ChangeNotes notes) {
     List<ActionVisitor> visitors = visitors();
     to.actions = toActionMap(notes, visitors, copy(visitors, to));
-    return to;
   }
 
-  public RevisionInfo addRevisionActions(
-      @Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
+  void addRevisionActions(@Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
     List<ActionVisitor> visitors = visitors();
     if (!visitors.isEmpty()) {
       if (changeInfo != null) {
@@ -106,7 +104,6 @@
       }
     }
     to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
-    return to;
   }
 
   private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
@@ -119,6 +116,8 @@
     copy.project = changeInfo.project;
     copy.branch = changeInfo.branch;
     copy.topic = changeInfo.topic;
+    copy.attentionSet =
+        changeInfo.attentionSet == null ? null : ImmutableMap.copyOf(changeInfo.attentionSet);
     copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
new file mode 100644
index 0000000..262fdc2
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+import java.util.function.Function;
+
+/** Add a specified user to the attention set. */
+public class AddToAttentionSetOp implements BatchUpdateOp {
+
+  public interface Factory {
+    AddToAttentionSetOp create(Account.Id attentionUserId, String reason);
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Account.Id attentionUserId;
+  private final String reason;
+
+  @Inject
+  AddToAttentionSetOp(
+      ChangeData.Factory changeDataFactory,
+      ChangeMessagesUtil cmUtil,
+      @Assisted Account.Id attentionUserId,
+      @Assisted String reason) {
+    this.changeDataFactory = changeDataFactory;
+    this.cmUtil = cmUtil;
+    this.attentionUserId = requireNonNull(attentionUserId, "user");
+    this.reason = requireNonNull(reason, "reason");
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    ChangeData changeData = changeDataFactory.create(ctx.getNotes());
+    Map<Account.Id, AttentionSetUpdate> attentionMap =
+        changeData.attentionSet().stream()
+            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
+    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
+    if (existingEntry != null && existingEntry.operation() == Operation.ADD) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    update.setAttentionSetUpdates(
+        ImmutableList.of(
+            AttentionSetUpdate.createForWrite(
+                attentionUserId, AttentionSetUpdate.Operation.ADD, reason)));
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+    String message = "Added to attention set: " + attentionUserId;
+    cmUtil.addChangeMessage(
+        update,
+        ChangeMessagesUtil.newMessage(ctx, message, ChangeMessagesUtil.TAG_UPDATE_ATTENTION_SET));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
new file mode 100644
index 0000000..6c6c765
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+/** REST resource that represents an entry in the attention set of a change. */
+public class AttentionSetEntryResource implements RestResource {
+  public static final TypeLiteral<RestView<AttentionSetEntryResource>> ATTENTION_SET_ENTRY_KIND =
+      new TypeLiteral<RestView<AttentionSetEntryResource>>() {};
+
+  public interface Factory {
+    AttentionSetEntryResource create(ChangeResource change, Account.Id id);
+  }
+
+  private final ChangeResource changeResource;
+  private final Account.Id accountId;
+
+  public AttentionSetEntryResource(ChangeResource changeResource, Account.Id accountId) {
+    this.changeResource = changeResource;
+    this.accountId = accountId;
+  }
+
+  public ChangeResource getChangeResource() {
+    return changeResource;
+  }
+
+  public Account.Id getAccountId() {
+    return accountId;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index b704205..e4148a5 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
@@ -49,6 +50,7 @@
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
@@ -60,6 +62,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.AttentionSetEntry;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -102,6 +105,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -504,6 +508,20 @@
     out.project = in.getProject().get();
     out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
+    if (!cd.attentionSet().isEmpty()) {
+      out.attentionSet =
+          cd.attentionSet().stream()
+              // This filtering should match GetAttentionSet.
+              .filter(a -> a.operation() == AttentionSetUpdate.Operation.ADD)
+              .collect(
+                  toImmutableMap(
+                      a -> a.account().get(),
+                      a ->
+                          new AttentionSetEntry(
+                              accountLoader.get(a.account()),
+                              Timestamp.from(a.timestamp()),
+                              a.reason())));
+    }
     out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
new file mode 100644
index 0000000..7118089
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+import java.util.function.Function;
+
+/** Remove a specified user from the attention set. */
+public class RemoveFromAttentionSetOp implements BatchUpdateOp {
+
+  public interface Factory {
+    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason);
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Account.Id attentionUserId;
+  private final String reason;
+
+  @Inject
+  RemoveFromAttentionSetOp(
+      ChangeData.Factory changeDataFactory,
+      ChangeMessagesUtil cmUtil,
+      @Assisted Account.Id attentionUserId,
+      @Assisted String reason) {
+    this.changeDataFactory = changeDataFactory;
+    this.cmUtil = cmUtil;
+    this.attentionUserId = requireNonNull(attentionUserId, "user");
+    this.reason = requireNonNull(reason, "reason");
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    ChangeData changeData = changeDataFactory.create(ctx.getNotes());
+    Map<Account.Id, AttentionSetUpdate> attentionMap =
+        changeData.attentionSet().stream()
+            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
+    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
+    if (existingEntry == null || existingEntry.operation() == Operation.REMOVE) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    update.setAttentionSetUpdates(
+        ImmutableList.of(
+            AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason)));
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+    String message = "Removed from attention set: " + attentionUserId;
+    cmUtil.addChangeMessage(
+        update,
+        ChangeMessagesUtil.newMessage(ctx, message, ChangeMessagesUtil.TAG_UPDATE_ATTENTION_SET));
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 287f3e7..86b6ed7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gson.Gson;
@@ -171,11 +171,11 @@
   private static class AttentionStatusInNoteDb {
 
     final String personIdent;
-    final AttentionStatus.Operation operation;
+    final AttentionSetUpdate.Operation operation;
     final String reason;
 
     AttentionStatusInNoteDb(
-        String personIndent, AttentionStatus.Operation operation, String reason) {
+        String personIndent, AttentionSetUpdate.Operation operation, String reason) {
       this.personIdent = personIndent;
       this.operation = operation;
       this.reason = reason;
@@ -183,7 +183,7 @@
   }
 
   /** The returned {@link Optional} holds the parsed entity or is empty if parsing failed. */
-  static Optional<AttentionStatus> attentionStatusFromJson(
+  static Optional<AttentionSetUpdate> attentionStatusFromJson(
       Instant timestamp, String attentionString) {
     AttentionStatusInNoteDb inNoteDb =
         gson.fromJson(attentionString, AttentionStatusInNoteDb.class);
@@ -193,18 +193,20 @@
     }
     Optional<Account.Id> account = NoteDbUtil.parseIdent(personIdent);
     return account.map(
-        id -> AttentionStatus.createFromRead(timestamp, id, inNoteDb.operation, inNoteDb.reason));
+        id ->
+            AttentionSetUpdate.createFromRead(timestamp, id, inNoteDb.operation, inNoteDb.reason));
   }
 
-  String attentionStatusToJson(AttentionStatus attentionStatus) {
+  String attentionSetUpdateToJson(AttentionSetUpdate attentionSetUpdate) {
     PersonIdent personIdent =
         new PersonIdent(
-            getUsername(attentionStatus.account()), getEmailAddress(attentionStatus.account()));
+            getUsername(attentionSetUpdate.account()),
+            getEmailAddress(attentionSetUpdate.account()));
     StringBuilder stringBuilder = new StringBuilder();
     appendIdentString(stringBuilder, personIdent.getName(), personIdent.getEmailAddress());
     return gson.toJson(
         new AttentionStatusInNoteDb(
-            stringBuilder.toString(), attentionStatus.operation(), attentionStatus.reason()));
+            stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
   }
 
   static void appendIdentString(StringBuilder stringBuilder, String name, String emailAddress) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 2de2195..84bd29b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -40,7 +40,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -376,8 +376,9 @@
     return state.reviewerUpdates();
   }
 
-  public ImmutableList<AttentionStatus> getAttentionUpdates() {
-    return state.attentionUpdates();
+  /** Returns the most recent update (i.e. status) per user. */
+  public ImmutableList<AttentionSetUpdate> getAttentionSet() {
+    return state.attentionSet();
   }
 
   /**
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index ed6039f..6b6a7ca 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -59,7 +59,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -118,7 +118,7 @@
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
-  private final Map<Account.Id, AttentionStatus> latestAttentionStatus;
+  private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -367,7 +367,7 @@
     }
 
     parseHashtags(commit);
-    parseAttentionUpdates(commit);
+    parseAttentionSetUpdates(commit);
     parseAssigneeUpdates(ts, commit);
 
     if (submissionId == null) {
@@ -578,11 +578,11 @@
     }
   }
 
-  private void parseAttentionUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
+  private void parseAttentionSetUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
     for (String attentionString : attentionStrings) {
 
-      Optional<AttentionStatus> attentionStatus =
+      Optional<AttentionSetUpdate> attentionStatus =
           ChangeNoteUtil.attentionStatusFromJson(
               Instant.ofEpochSecond(commit.getCommitTime()), attentionString);
       if (!attentionStatus.isPresent()) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 67faa33..9cd4af3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -56,7 +56,7 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionStatusProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@@ -119,7 +119,7 @@
       ReviewerByEmailSet pendingReviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
-      List<AttentionStatus> attentionStatusUpdates,
+      List<AttentionSetUpdate> attentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -170,7 +170,7 @@
         .pendingReviewersByEmail(pendingReviewersByEmail)
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
-        .attentionUpdates(attentionStatusUpdates)
+        .attentionSet(attentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
@@ -305,7 +305,8 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
-  abstract ImmutableList<AttentionStatus> attentionUpdates();
+  /** Returns the most recent update (i.e. current status status) per user. */
+  abstract ImmutableList<AttentionSetUpdate> attentionSet();
 
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
@@ -384,7 +385,7 @@
           .pendingReviewersByEmail(ReviewerByEmailSet.empty())
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
-          .attentionUpdates(ImmutableList.of())
+          .attentionSet(ImmutableList.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -418,7 +419,7 @@
 
     abstract Builder reviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates);
 
-    abstract Builder attentionUpdates(List<AttentionStatus> attentionUpdates);
+    abstract Builder attentionSet(List<AttentionSetUpdate> attentionSetUpdates);
 
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
@@ -487,7 +488,7 @@
 
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
-      object.attentionUpdates().forEach(u -> b.addAttentionStatus(toAttentionStatusProto(u)));
+      object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
@@ -571,12 +572,13 @@
           .build();
     }
 
-    private static AttentionStatusProto toAttentionStatusProto(AttentionStatus attentionStatus) {
-      return AttentionStatusProto.newBuilder()
-          .setTimestampMillis(attentionStatus.timestamp().toEpochMilli())
-          .setAccount(attentionStatus.account().get())
-          .setOperation(attentionStatus.operation().name())
-          .setReason(attentionStatus.reason())
+    private static AttentionSetUpdateProto toAttentionSetUpdateProto(
+        AttentionSetUpdate attentionSetUpdate) {
+      return AttentionSetUpdateProto.newBuilder()
+          .setTimestampMillis(attentionSetUpdate.timestamp().toEpochMilli())
+          .setAccount(attentionSetUpdate.account().get())
+          .setOperation(attentionSetUpdate.operation().name())
+          .setReason(attentionSetUpdate.reason())
           .build();
     }
 
@@ -620,7 +622,7 @@
               .allPastReviewers(
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
-              .attentionUpdates(toAttentionUpdateList(proto.getAttentionStatusList()))
+              .attentionSet(toAttentionSetUpdateList(proto.getAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -719,15 +721,15 @@
       return b.build();
     }
 
-    private static ImmutableList<AttentionStatus> toAttentionUpdateList(
-        List<AttentionStatusProto> protos) {
-      ImmutableList.Builder<AttentionStatus> b = ImmutableList.builder();
-      for (AttentionStatusProto proto : protos) {
+    private static ImmutableList<AttentionSetUpdate> toAttentionSetUpdateList(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+      for (AttentionSetUpdateProto proto : protos) {
         b.add(
-            AttentionStatus.createFromRead(
+            AttentionSetUpdate.createFromRead(
                 Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getAccount()),
-                AttentionStatus.Operation.valueOf(proto.getOperation()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
                 proto.getReason()));
       }
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 4492050..0de090f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -54,7 +54,7 @@
 import com.google.common.collect.TreeBasedTable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.Project;
@@ -128,7 +128,7 @@
   private String submissionId;
   private String topic;
   private String commit;
-  private List<AttentionStatus> attentionUpdates;
+  private List<AttentionSetUpdate> attentionSetUpdates;
   private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
@@ -369,15 +369,15 @@
    * All updates must have a timestamp of null since we use the commit's timestamp. There also must
    * not be multiple updates for a single user.
    */
-  void setAttentionUpdates(List<AttentionStatus> attentionUpdates) {
+  public void setAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates) {
     checkArgument(
-        attentionUpdates.stream().noneMatch(x -> x.timestamp() != null),
+        attentionSetUpdates.stream().noneMatch(a -> a.timestamp() != null),
         "must not specify timestamp for write");
     checkArgument(
-        attentionUpdates.stream().map(AttentionStatus::account).distinct().count()
-            == attentionUpdates.size(),
+        attentionSetUpdates.stream().map(AttentionSetUpdate::account).distinct().count()
+            == attentionSetUpdates.size(),
         "must not specify multiple updates for single user");
-    this.attentionUpdates = attentionUpdates;
+    this.attentionSetUpdates = attentionSetUpdates;
   }
 
   public void setAssignee(Account.Id assignee) {
@@ -588,9 +588,9 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
-    if (attentionUpdates != null) {
-      for (AttentionStatus attentionUpdate : attentionUpdates) {
-        addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionStatusToJson(attentionUpdate));
+    if (attentionSetUpdates != null) {
+      for (AttentionSetUpdate attentionSetUpdate : attentionSetUpdates) {
+        addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
       }
     }
 
@@ -730,7 +730,7 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && attentionUpdates == null
+        && attentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 563ebb7..3dce678 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -297,6 +298,7 @@
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
+  private ImmutableList<AttentionSetUpdate> attentionSet;
   private int parentCount;
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
@@ -598,6 +600,17 @@
     return true;
   }
 
+  /** Returns the most recent update (i.e. status) per user. */
+  public ImmutableList<AttentionSetUpdate> attentionSet() {
+    if (attentionSet == null) {
+      if (!lazyLoad) {
+        return ImmutableList.of();
+      }
+      attentionSet = notes().getAttentionSet();
+    }
+    return attentionSet;
+  }
+
   /** @return patches for the change, in patch set ID order. */
   public Collection<PatchSet> patchSets() {
     if (patchSets == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
new file mode 100644
index 0000000..5176fe9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Adds a single user to the attention set. */
+@Singleton
+public class AddToAttentionSet
+    implements RestCollectionModifyView<
+        ChangeResource, AttentionSetEntryResource, AddToAttentionSetInput> {
+  private final BatchUpdate.Factory updateFactory;
+  private final AccountResolver accountResolver;
+  private final AddToAttentionSetOp.Factory opFactory;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  AddToAttentionSet(
+      BatchUpdate.Factory updateFactory,
+      AccountResolver accountResolver,
+      AddToAttentionSetOp.Factory opFactory,
+      AccountLoader.Factory accountLoaderFactory,
+      PermissionBackend permissionBackend) {
+    this.updateFactory = updateFactory;
+    this.accountResolver = accountResolver;
+    this.opFactory = opFactory;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource changeResource, AddToAttentionSetInput input)
+      throws Exception {
+    input.user = Strings.nullToEmpty(input.user).trim();
+    if (input.user.isEmpty()) {
+      throw new BadRequestException("missing field: user");
+    }
+    input.reason = Strings.nullToEmpty(input.reason).trim();
+    if (input.reason.isEmpty()) {
+      throw new BadRequestException("missing field: reason");
+    }
+
+    Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+    try {
+      permissionBackend
+          .absentUser(attentionUserId)
+          .change(changeResource.getNotes())
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new AuthException("read not permitted for " + attentionUserId, e);
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason);
+      bu.addOp(changeResource.getId(), op);
+      bu.execute();
+      return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
new file mode 100644
index 0000000..45d78dc
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AttentionSet implements ChildCollection<ChangeResource, AttentionSetEntryResource> {
+  private final DynamicMap<RestView<AttentionSetEntryResource>> views;
+  private final AccountResolver accountResolver;
+  private final GetAttentionSet getAttentionSet;
+
+  @Inject
+  AttentionSet(
+      DynamicMap<RestView<AttentionSetEntryResource>> views,
+      GetAttentionSet getAttentionSet,
+      AccountResolver accountResolver) {
+    this.views = views;
+    this.accountResolver = accountResolver;
+    this.getAttentionSet = getAttentionSet;
+  }
+
+  @Override
+  public DynamicMap<RestView<AttentionSetEntryResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ChangeResource> list() throws ResourceNotFoundException {
+    return getAttentionSet;
+  }
+
+  @Override
+  public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
+    try {
+      Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
+      return new AttentionSetEntryResource(changeResource, accountId);
+    } catch (UnresolvableAccountException e) {
+      throw new ResourceNotFoundException(idString, e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
new file mode 100644
index 0000000..5d6d03d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.common.AttentionSetEntry;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.List;
+
+/** Reads the list of users currently in the attention set. */
+@Singleton
+public class GetAttentionSet implements RestReadView<ChangeResource> {
+
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  GetAttentionSet(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<List<AttentionSetEntry>> apply(ChangeResource changeResource)
+      throws PermissionBackendException {
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    ImmutableList<AttentionSetEntry> response =
+        changeResource.getNotes().getAttentionSet().stream()
+            // This filtering should match ChangeJson.
+            .filter(a -> a.operation() == Operation.ADD)
+            .map(
+                a ->
+                    new AttentionSetEntry(
+                        accountLoader.get(a.account()), Timestamp.from(a.timestamp()), a.reason()))
+            .collect(toImmutableList());
+    accountLoader.fill();
+    return Response.ok(response);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 453b4df..387d0a8 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.change.AttentionSetEntryResource.ATTENTION_SET_ENTRY_KIND;
 import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
 import static com.google.gerrit.server.change.ChangeMessageResource.CHANGE_MESSAGE_KIND;
 import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.DeleteChangeOp;
@@ -38,6 +40,7 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
@@ -72,6 +75,7 @@
     DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
     DynamicMap.mapOf(binder(), VOTE_KIND);
     DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
+    DynamicMap.mapOf(binder(), ATTENTION_SET_ENTRY_KIND);
 
     postOnCollection(CHANGE_KIND).to(CreateChange.class);
     get(CHANGE_KIND).to(GetChange.class);
@@ -79,6 +83,10 @@
     get(CHANGE_KIND, "detail").to(GetDetail.class);
     get(CHANGE_KIND, "topic").to(GetTopic.class);
     get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
+    child(CHANGE_KIND, "attention").to(AttentionSet.class);
+    delete(ATTENTION_SET_ENTRY_KIND).to(RemoveFromAttentionSet.class);
+    post(ATTENTION_SET_ENTRY_KIND, "delete").to(RemoveFromAttentionSet.class);
+    postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
     get(CHANGE_KIND, "assignee").to(GetAssignee.class);
     get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
     put(CHANGE_KIND, "assignee").to(PutAssignee.class);
@@ -207,5 +215,7 @@
     factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
     factory(SetTopicOp.Factory.class);
+    factory(AddToAttentionSetOp.Factory.class);
+    factory(RemoveFromAttentionSetOp.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
new file mode 100644
index 0000000..ccf375a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Removes a single user from the attention set. */
+public class RemoveFromAttentionSet
+    implements RestModifyView<AttentionSetEntryResource, RemoveFromAttentionSetInput> {
+  private final BatchUpdate.Factory updateFactory;
+  private final RemoveFromAttentionSetOp.Factory opFactory;
+
+  @Inject
+  RemoveFromAttentionSet(
+      BatchUpdate.Factory updateFactory, RemoveFromAttentionSetOp.Factory opFactory) {
+    this.updateFactory = updateFactory;
+    this.opFactory = opFactory;
+  }
+
+  @Override
+  public Response<Object> apply(
+      AttentionSetEntryResource attentionResource, RemoveFromAttentionSetInput input)
+      throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
+          UpdateException {
+    if (input == null) {
+      throw new BadRequestException("input may not be null");
+    }
+    input.reason = Strings.nullToEmpty(input.reason).trim();
+    if (input.reason.isEmpty()) {
+      throw new BadRequestException("missing field: reason");
+    }
+    ChangeResource changeResource = attentionResource.getChangeResource();
+    try (BatchUpdate bu =
+        updateFactory.create(
+            changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+      RemoveFromAttentionSetOp op =
+          opFactory.create(attentionResource.getAccountId(), input.reason);
+      bu.addOp(changeResource.getId(), op);
+      bu.execute();
+    }
+    return Response.none();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
index 27dd16a..4163e17 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -53,14 +53,14 @@
     PushOneCommit.Result r = createChange("refs/for/master");
     String branch = r.getChange().change().getDest().branch();
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectResultInfo checkResult =
         gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
     assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
   }
 
@@ -121,7 +121,7 @@
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectResultInfo checkResult =
@@ -132,7 +132,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
   }
 
@@ -144,7 +144,7 @@
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
@@ -156,7 +156,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -170,7 +170,7 @@
 
     serverSideTestRepo.commit(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
@@ -179,7 +179,7 @@
     CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
     assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     input.autoCloseableChangesCheck.maxCommits = 2;
@@ -190,7 +190,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -204,7 +204,7 @@
 
     serverSideTestRepo.commit(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
@@ -213,7 +213,7 @@
     CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
     assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     input.autoCloseableChangesCheck.skipCommits = 1;
@@ -224,7 +224,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 70fcfc4..2c2abfe 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -303,13 +303,7 @@
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
-        assertThrows(
-            AuthException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChange().getId().get())
-                    .current()
-                    .review(ReviewInput.approve()));
+        assertThrows(AuthException.class, () -> change(r).current().review(ReviewInput.approve()));
     assertThat(thrown).hasMessageThat().contains("is restricted");
   }
 
@@ -560,7 +554,7 @@
     PushOneCommit.Result r = push.to("refs/for/master%topic=someTopic");
 
     // Verify before the cherry-pick that the change has exactly 1 message.
-    ChangeApi changeApi = gApi.changes().id(r.getChange().getId().get());
+    ChangeApi changeApi = change(r);
     assertThat(changeApi.get().messages).hasSize(1);
 
     // Cherry-pick the change to the other branch, that should fail with a conflict.
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index f190d59..d1e8cc5 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -437,7 +437,7 @@
     r.assertErrorStatus("change " + url + " closed");
 
     // Check change message that was added on auto-close
-    ChangeInfo change = gApi.changes().id(r.getChange().getId().get()).get();
+    ChangeInfo change = change(r).get();
     assertThat(Iterables.getLast(change.messages).message)
         .isEqualTo("Change has been successfully pushed.");
   }
@@ -477,7 +477,7 @@
     r.assertErrorStatus("change " + url + " closed");
 
     // Check that new commit was added as patch set
-    ChangeInfo change = gApi.changes().id(r.getChange().getId().get()).get();
+    ChangeInfo change = change(r).get();
     assertThat(change.revisions).hasSize(2);
     assertThat(change.currentRevision).isEqualTo(c.name());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 83bc3eb..574e919 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -63,6 +63,8 @@
           RestCall.get("/changes/%s/comments"),
           RestCall.get("/changes/%s/robotcomments"),
           RestCall.get("/changes/%s/drafts"),
+          RestCall.get("/changes/%s/attention"),
+          RestCall.post("/changes/%s/attention"),
           RestCall.get("/changes/%s/assignee"),
           RestCall.get("/changes/%s/past_assignees"),
           RestCall.put("/changes/%s/assignee"),
@@ -267,6 +269,11 @@
           // Delete content of a file in an existing change edit.
           RestCall.delete("/changes/%s/edit/%s"));
 
+  private static final ImmutableList<RestCall> ATTENTION_SET_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/attention/%s/delete"),
+          RestCall.delete("/changes/%s/attention/%s"));
+
   private static final String FILENAME = "test.txt";
 
   @Test
@@ -477,6 +484,14 @@
     RestApiCallHelper.execute(adminRestSession, CHANGE_EDIT_ENDPOINTS, changeId, FILENAME);
   }
 
+  @Test
+  public void attentionSetEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    RestApiCallHelper.execute(
+        adminRestSession, ATTENTION_SET_ENDPOINTS, changeId, user.id().toString());
+  }
+
   private static Comment.Range createRange(
       int startLine, int startCharacter, int endLine, int endCharacter) {
     Comment.Range range = new Comment.Range();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 420ddda..2d47dd8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -188,11 +188,11 @@
   }
 
   private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).getAssignee();
+    return change(r).getAssignee();
   }
 
   private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).getPastAssignees();
+    return change(r).getPastAssignees();
   }
 
   private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
@@ -203,10 +203,10 @@
   private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
     AssigneeInput input = new AssigneeInput();
     input.assignee = identifieer;
-    return gApi.changes().id(r.getChange().getId().get()).setAssignee(input);
+    return change(r).setAssignee(input);
   }
 
   private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).deleteAssignee();
+    return change(r).deleteAssignee();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
new file mode 100644
index 0000000..caa8832
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseClockStep(clockStepUnit = TimeUnit.MINUTES)
+public class AttentionSetIT extends AbstractDaemonTest {
+  /** Simulates a fake clock. Uses second granularity. */
+  private static class FakeClock implements LongSupplier {
+    Instant now = Instant.now();
+
+    @Override
+    public long getAsLong() {
+      return TimeUnit.SECONDS.toMillis(now.getEpochSecond());
+    }
+
+    Instant now() {
+      return Instant.ofEpochSecond(now.getEpochSecond());
+    }
+
+    void advance(Duration duration) {
+      now = now.plus(duration);
+    }
+  }
+
+  private FakeClock fakeClock = new FakeClock();
+
+  @Before
+  public void setUp() {
+    TimeUtil.setCurrentMillisSupplier(fakeClock);
+  }
+
+  @Test
+  public void emptyAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void addUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    int accountId =
+        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "first"))._accountId;
+    assertThat(accountId).isEqualTo(user.id().get());
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Second add is ignored.
+    accountId =
+        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "second"))._accountId;
+    assertThat(accountId).isEqualTo(user.id().get());
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+  }
+
+  @Test
+  public void addMultipleUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Instant timestamp1 = fakeClock.now();
+    int accountId1 =
+        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "user"))._accountId;
+    assertThat(accountId1).isEqualTo(user.id().get());
+    fakeClock.advance(Duration.ofSeconds(42));
+    Instant timestamp2 = fakeClock.now();
+    int accountId2 =
+        change(r)
+            .addToAttentionSet(new AddToAttentionSetInput(admin.id().toString(), "admin"))
+            ._accountId;
+    assertThat(accountId2).isEqualTo(admin.id().get());
+
+    AttentionSetUpdate expectedAttentionSetUpdate1 =
+        AttentionSetUpdate.createFromRead(
+            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+    AttentionSetUpdate expectedAttentionSetUpdate2 =
+        AttentionSetUpdate.createFromRead(
+            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
+  }
+
+  @Test
+  public void removeUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "added"));
+    fakeClock.advance(Duration.ofSeconds(42));
+    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("removed"));
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Second removal is ignored.
+    fakeClock.advance(Duration.ofSeconds(42));
+    change(r)
+        .attention(user.id().toString())
+        .remove(new RemoveFromAttentionSetInput("removed again"));
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+  }
+
+  @Test
+  public void removeUnrelatedUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("foo"));
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index c57a035..0099fe6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -226,7 +226,7 @@
     HashtagsInput input = new HashtagsInput();
     input.add = Sets.newHashSet("tag3", "tag4");
     input.remove = Sets.newHashSet("tag1");
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
     assertThatGet(r).containsExactly("tag2", "tag3", "tag4");
     assertMessage(r, "Hashtags added: tag3, tag4\nHashtag removed: tag1");
 
@@ -235,7 +235,7 @@
     input = new HashtagsInput();
     input.add = Sets.newHashSet("tag3", "tag4");
     input.remove = Sets.newHashSet("tag3");
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
     assertThatGet(r).containsExactly("tag1", "tag2", "tag4");
     assertMessage(r, "Hashtag removed: tag3");
   }
@@ -271,19 +271,19 @@
   }
 
   private IterableSubject assertThatGet(PushOneCommit.Result r) throws Exception {
-    return assertThat(gApi.changes().id(r.getChange().getId().get()).getHashtags());
+    return assertThat(change(r).getHashtags());
   }
 
   private void addHashtags(PushOneCommit.Result r, String... toAdd) throws Exception {
     HashtagsInput input = new HashtagsInput();
     input.add = Sets.newHashSet(toAdd);
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
   }
 
   private void removeHashtags(PushOneCommit.Result r, String... toRemove) throws Exception {
     HashtagsInput input = new HashtagsInput();
     input.remove = Sets.newHashSet(toRemove);
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
   }
 
   private void assertMessage(PushOneCommit.Result r, String expectedMessage) throws Exception {
@@ -299,8 +299,7 @@
   }
 
   private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
-    ChangeMessageInfo lastMessage =
-        Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
+    ChangeMessageInfo lastMessage = Iterables.getLast(change(r).get().messages, null);
     assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
     return lastMessage;
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 74fee65..0674dc0 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -45,7 +45,7 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionStatusProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@@ -600,31 +600,31 @@
   }
 
   @Test
-  public void serializeAttentionUpdates() throws Exception {
+  public void serializeAttentionSetUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
-            .attentionUpdates(
+            .attentionSet(
                 ImmutableList.of(
-                    AttentionStatus.createFromRead(
+                    AttentionSetUpdate.createFromRead(
                         Instant.EPOCH.plusSeconds(23),
                         Account.id(1000),
-                        AttentionStatus.Operation.ADD,
+                        AttentionSetUpdate.Operation.ADD,
                         "reason 1"),
-                    AttentionStatus.createFromRead(
+                    AttentionSetUpdate.createFromRead(
                         Instant.EPOCH.plusSeconds(42),
                         Account.id(2000),
-                        AttentionStatus.Operation.REMOVE,
+                        AttentionSetUpdate.Operation.REMOVE,
                         "reason 2")))
             .build(),
         newProtoBuilder()
-            .addAttentionStatus(
-                AttentionStatusProto.newBuilder()
+            .addAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
                     .setTimestampMillis(23_000) // epoch millis
                     .setAccount(1000)
                     .setOperation("ADD")
                     .setReason("reason 1"))
-            .addAttentionStatus(
-                AttentionStatusProto.newBuilder()
+            .addAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
                     .setTimestampMillis(42_000) // epoch millis
                     .setAccount(2000)
                     .setOperation("REMOVE")
@@ -789,8 +789,8 @@
                     "reviewerUpdates",
                     new TypeLiteral<ImmutableList<ReviewerStatusUpdate>>() {}.getType())
                 .put(
-                    "attentionUpdates",
-                    new TypeLiteral<ImmutableList<AttentionStatus>>() {}.getType())
+                    "attentionSet",
+                    new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
                 .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 4e068ba..f5a6dc3 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -38,8 +38,8 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AttentionStatus;
-import com.google.gerrit.entities.AttentionStatus.Operation;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -694,51 +694,51 @@
   public void defaultAttentionSetIsEmpty() throws Exception {
     Change c = newChange();
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getAttentionUpdates()).isEmpty();
+    assertThat(notes.getAttentionSet()).isEmpty();
   }
 
   @Test
   public void addAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    AttentionStatus attentionStatus =
-        AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionUpdates(ImmutableList.of(attentionStatus));
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.setAttentionSetUpdates(ImmutableList.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getAttentionUpdates()).containsExactly(addTimestamp(attentionStatus, c));
+    assertThat(notes.getAttentionSet()).containsExactly(addTimestamp(attentionSetUpdate, c));
   }
 
   @Test
   public void filterLatestAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    AttentionStatus attentionStatus =
-        AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionUpdates(ImmutableList.of(attentionStatus));
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.setAttentionSetUpdates(ImmutableList.of(attentionSetUpdate));
     update.commit();
     update = newUpdate(c, changeOwner);
-    attentionStatus =
-        AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
-    update.setAttentionUpdates(ImmutableList.of(attentionStatus));
+    attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.setAttentionSetUpdates(ImmutableList.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getAttentionUpdates()).containsExactly(addTimestamp(attentionStatus, c));
+    assertThat(notes.getAttentionSet()).containsExactly(addTimestamp(attentionSetUpdate, c));
   }
 
   @Test
   public void addAttentionStatus_rejectTimestamp() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    AttentionStatus attentionStatus =
-        AttentionStatus.createFromRead(
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
             Instant.now(), changeOwner.getAccountId(), Operation.ADD, "test");
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
-            () -> update.setAttentionUpdates(ImmutableList.of(attentionStatus)));
+            () -> update.setAttentionSetUpdates(ImmutableList.of(attentionSetUpdate)));
     assertThat(thrown).hasMessageThat().contains("must not specify timestamp for write");
   }
 
@@ -746,14 +746,16 @@
   public void addAttentionStatus_rejectMultiplePerUser() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    AttentionStatus attentionStatus0 =
-        AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 0");
-    AttentionStatus attentionStatus1 =
-        AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 1");
+    AttentionSetUpdate attentionSetUpdate0 =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 0");
+    AttentionSetUpdate attentionSetUpdate1 =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 1");
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
-            () -> update.setAttentionUpdates(ImmutableList.of(attentionStatus0, attentionStatus1)));
+            () ->
+                update.setAttentionSetUpdates(
+                    ImmutableList.of(attentionSetUpdate0, attentionSetUpdate1)));
     assertThat(thrown)
         .hasMessageThat()
         .contains("must not specify multiple updates for single user");
@@ -763,17 +765,18 @@
   public void addAttentionStatusForMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    AttentionStatus attentionStatus0 =
-        AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    AttentionStatus attentionStatus1 =
-        AttentionStatus.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
+    AttentionSetUpdate attentionSetUpdate0 =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    AttentionSetUpdate attentionSetUpdate1 =
+        AttentionSetUpdate.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
 
-    update.setAttentionUpdates(ImmutableList.of(attentionStatus0, attentionStatus1));
+    update.setAttentionSetUpdates(ImmutableList.of(attentionSetUpdate0, attentionSetUpdate1));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getAttentionUpdates())
-        .containsExactly(addTimestamp(attentionStatus0, c), addTimestamp(attentionStatus1, c));
+    assertThat(notes.getAttentionSet())
+        .containsExactly(
+            addTimestamp(attentionSetUpdate0, c), addTimestamp(attentionSetUpdate1, c));
   }
 
   @Test
@@ -3230,12 +3233,12 @@
     return tr.parseBody(commit);
   }
 
-  private AttentionStatus addTimestamp(AttentionStatus attentionStatus, Change c) {
+  private AttentionSetUpdate addTimestamp(AttentionSetUpdate attentionSetUpdate, Change c) {
     Timestamp timestamp = newNotes(c).getChange().getLastUpdatedOn();
-    return AttentionStatus.createFromRead(
+    return AttentionSetUpdate.createFromRead(
         timestamp.toInstant(),
-        attentionStatus.account(),
-        attentionStatus.operation(),
-        attentionStatus.reason());
+        attentionSetUpdate.account(),
+        attentionSetUpdate.operation(),
+        attentionSetUpdate.reason());
   }
 }
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..2b4e158 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
@@ -969,7 +969,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. */
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..7a074a9 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
@@ -921,12 +921,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 +939,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 +949,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 +996,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' +
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..b951237 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
@@ -1837,8 +1837,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/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
index ccf22e6..3f4fc42 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
@@ -26,7 +26,7 @@
         width: 100%;
       }
       ol {
-        margin-left: var(--spacing-l);
+        margin-left: var(--spacing-xl);
         list-style: decimal;
       }
       p {
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-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/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index 785e8f9..70806e2 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -497,6 +497,12 @@
   }
 
   _showKeyboardShortcuts(e) {
+    // same shortcut should close the dialog if pressed again
+    // when dialog is open
+    if (this.$.keyboardShortcuts.opened) {
+      this.$.keyboardShortcuts.close();
+      return;
+    }
     if (this.shouldSuppressKeyboardShortcut(e)) { return; }
     this.$.keyboardShortcuts.open();
   }
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..fea44b4 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
    */
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 {
diff --git a/proto/cache.proto b/proto/cache.proto
index 5fc5e68..c80d51b 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -208,17 +208,17 @@
   }
   repeated AssigneeStatusUpdateProto assignee_update = 22;
 
-  // An update to the attention set of the change. See class AttentionStatus for
-  // context.
-  message AttentionStatusProto {
+  // An update to the attention set of the change. See class AttentionSetUpdate
+  // for context.
+  message AttentionSetUpdateProto {
     // Epoch millis.
     int64 timestamp_millis = 1;
     int32 account = 2;
-    // Maps to enum AttentionStatus.Operation
+    // Maps to enum AttentionSetUpdate.Operation
     string operation = 3;
     string reason = 4;
   }
-  repeated AttentionStatusProto attention_status = 23;
+  repeated AttentionSetUpdateProto attention_set_update = 23;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey