Merge "Migrate gr-change-star to lit element"
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 0318cd7..d8b6250 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -113,7 +113,7 @@
 * `receivecommits/ps_revision_missing`: errors due to patch set revision missing
 * `receivecommits/push_count`: number of pushes
 ** `kind`:
-   The push kind (direct vs. magic).
+   The push kind (magic, direct or direct_submit).
 ** `project`:
    The name of the project for which the push is done.
 ** `type`:
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index cc8d813..6e6b9d7 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -565,6 +565,11 @@
 cherry-picked locally using the git cherry-pick command and then
 pushed to Gerrit.
 
+[[pure-revert]]
+is:pure-revert::
++
+True if the change is a pure revert.
+
 [[status]]
 status:open, status:pending, status:new::
 +
@@ -772,6 +777,22 @@
 +
 Matches changes with label voted with any score.
 
+`label:Code-Review=+1,count=2`::
++
+Matches changes with exactly two +1 votes to the code-review label. The {MAX,
+MIN, ANY} votes can also be used, for example `label:Code-Review=MAX,count=2` is
+equivalent to `label:Code-Review=2,count=2` (if 2 is the maximum positive vote
+for the code review label). The maximum supported value for `count` is 5.
+`count=0` is not allowed and the query request will fail with `400 Bad Request`.
+
+`label:Code-Review=+1,count>=2`::
++
+Matches changes having two or more +1 votes to the code-review label. Can also
+be used with the {MAX, MIN, ANY} label votes. All operators `>`, `>=`, `<`, `<=`
+are supported.
+Note that a query like `label:Code-Review=+1,count<x` will not match with
+changes having zero +1 votes to this label.
+
 `label:Non-Author-Code-Review=need`::
 +
 Matches changes where the submit rules indicate that a label named
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 66e7d80..f4c7a92 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -747,14 +747,19 @@
       return;
     }
 
-    if (!magicCommands.isEmpty()) {
-      metrics.pushCount.increment("magic", project.getName(), getUpdateType(magicCommands));
-    }
-    if (!regularCommands.isEmpty()) {
-      metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
-    }
-
     try {
+      if (!magicCommands.isEmpty()) {
+        parseMagicBranch(Iterables.getLast(magicCommands));
+        // Using the submit option submits the created change(s) immediately without checking labels
+        // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that
+        // code review is being done.
+        String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
+        metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
+      }
+      if (!regularCommands.isEmpty()) {
+        metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+      }
+
       if (!regularCommands.isEmpty()) {
         handleRegularCommands(regularCommands, progress);
         return;
@@ -763,7 +768,6 @@
       boolean first = true;
       for (ReceiveCommand cmd : magicCommands) {
         if (first) {
-          parseMagicBranch(cmd);
           first = false;
         } else {
           reject(cmd, "duplicate request");
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index df1888b..e50482d 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -21,7 +21,7 @@
 
   @VisibleForTesting
   public static final String ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP =
-      "only users with Toogle-Wip-State permission can modify Work-in-Progress";
+      "only users with Toggle-Wip-State permission can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
       "Contact an administrator to fix the permissions";
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 6ebc293..2cdb7c8 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -33,6 +33,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -412,6 +413,10 @@
       integer(ChangeQueryBuilder.FIELD_REVERTOF)
           .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
 
+  public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
+      fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+          .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -612,8 +617,10 @@
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
+    Table<String, Short, Integer> voteCounts = HashBasedTable.create();
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
+        increment(voteCounts, a.label(), a.value());
         Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
 
         allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
@@ -626,9 +633,67 @@
       }
     }
     allApprovals.addAll(distinctApprovals);
+    allApprovals.addAll(getCountLabelFormats(voteCounts, cd));
     return allApprovals;
   }
 
+  private static void increment(Table<String, Short, Integer> table, String k1, short k2) {
+    if (!table.contains(k1, k2)) {
+      table.put(k1, k2, 1);
+    } else {
+      int val = table.get(k1, k2);
+      table.put(k1, k2, val + 1);
+    }
+  }
+
+  private static List<String> getCountLabelFormats(
+      Table<String, Short, Integer> voteCounts, ChangeData cd) {
+    List<String> allFormats = new ArrayList<>();
+    for (String label : voteCounts.rowMap().keySet()) {
+      Optional<LabelType> labelType = cd.getLabelTypes().byLabel(label);
+      Map<Short, Integer> row = voteCounts.row(label);
+      for (short vote : row.keySet()) {
+        int count = row.get(vote);
+        allFormats.addAll(getCountLabelFormats(labelType, label, vote, count));
+      }
+    }
+    return allFormats;
+  }
+
+  private static List<String> getCountLabelFormats(
+      Optional<LabelType> labelType, String label, short vote, int count) {
+    List<String> formats =
+        getMagicLabelFormats(label, vote, labelType, /* accountId= */ null, /* count= */ count);
+    formats.add(formatLabel(label, vote, count));
+    return formats;
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
+      String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
+    return getMagicLabelFormats(label, labelVal, labelType, accountId, /* count= */ null);
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
+      String label,
+      short labelVal,
+      Optional<LabelType> labelType,
+      @Nullable Account.Id accountId,
+      @Nullable Integer count) {
+    List<String> labels = new ArrayList<>();
+    if (labelType.isPresent()) {
+      if (labelVal == labelType.get().getMaxPositive()) {
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId, count));
+      }
+      if (labelVal == labelType.get().getMaxNegative()) {
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId, count));
+      }
+    }
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId, count));
+    return labels;
+  }
+
   private static List<String> getLabelOwnerFormats(
       PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
     List<String> allFormats = new ArrayList<>();
@@ -653,22 +718,6 @@
     return allFormats;
   }
 
-  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
-  private static List<String> getMagicLabelFormats(
-      String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
-    List<String> labels = new ArrayList<>();
-    if (labelType.isPresent()) {
-      if (labelVal == labelType.get().getMaxPositive()) {
-        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
-      }
-      if (labelVal == labelType.get().getMaxNegative()) {
-        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
-      }
-    }
-    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
-    return labels;
-  }
-
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -743,21 +792,33 @@
                       decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
-    return formatLabel(label, value, null);
+    return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
+  }
+
+  public static String formatLabel(String label, int value, @Nullable Integer count) {
+    return formatLabel(label, value, /* accountId= */ null, count);
   }
 
   public static String formatLabel(String label, int value, Account.Id accountId) {
+    return formatLabel(label, value, accountId, /* count= */ null);
+  }
+
+  public static String formatLabel(
+      String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + (value >= 0 ? "+" : "")
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
-  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+  public static String formatLabel(
+      String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + "="
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
   private static String formatAccount(Account.Id accountId) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9339d62..ee93065 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -183,9 +183,18 @@
       new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
 
   /** Added new field {@link ChangeField#UPLOADER}. */
+  @Deprecated
   static final Schema<ChangeData> V71 =
       new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
 
+  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
+  @Deprecated
+  static final Schema<ChangeData> V72 =
+      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
+
+  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
+  static final Schema<ChangeData> V73 = schema(V72, false);
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 3afdcdd..7e50c6f 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -342,4 +342,12 @@
   public static Predicate<ChangeData> submitRuleStatus(String value) {
     return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
   }
+
+  /**
+   * Returns a predicate that matches with changes that are pure reverts if {@code value} is equal
+   * to "1", or non-pure reverts if {@code value} is "0".
+   */
+  public static Predicate<ChangeData> pureRevert(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 8006c61..d435df1 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -84,6 +84,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.PredicateArgs.ValOp;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.submit.SubmitDryRun;
 import com.google.inject.Inject;
@@ -204,6 +205,7 @@
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
   public static final String FIELD_REVERTOF = "revertof";
+  public static final String FIELD_PURE_REVERT = "ispurerevert";
   public static final String FIELD_CHERRYPICK = "cherrypick";
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
@@ -213,6 +215,7 @@
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+  public static final String ARG_COUNT = "count";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
 
@@ -704,6 +707,11 @@
       return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
+    if ("pure-revert".equalsIgnoreCase(value)) {
+      checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+      return ChangePredicates.pureRevert("1");
+    }
+
     if ("submittable".equalsIgnoreCase(value)) {
       // SubmittablePredicate will match if *any* of the submit records are OK,
       // but we need to check that they're *all* OK, so check that none of the
@@ -940,6 +948,8 @@
       throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
+    Integer count = null;
+    PredicateArgs.Operator countOp = null;
 
     // Parse for:
     // label:Code-Review=1,user=jsmith or
@@ -950,6 +960,7 @@
     // Special case: votes by owners can be tracked with ",owner":
     // label:Code-Review+2,owner
     // label:Code-Review+2,user=owner
+    // label:Code-Review+1,count=2
     List<String> splitReviewer = Lists.newArrayList(Splitter.on(',').limit(2).split(name));
     name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1'
 
@@ -957,17 +968,40 @@
       // process the user/group piece
       PredicateArgs lblArgs = new PredicateArgs(splitReviewer.get(1));
 
-      for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
-        if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          if (pair.getValue().equals(ARG_ID_OWNER)) {
+      // Disallow using the "count=" arg in conjunction with the "user=" or "group=" args. to avoid
+      // unnecessary complexity.
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_USER);
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_GROUP);
+
+      for (Map.Entry<String, ValOp> pair : lblArgs.keyValue.entrySet()) {
+        String key = pair.getKey();
+        String value = pair.getValue().value();
+        PredicateArgs.Operator operator = pair.getValue().operator();
+        if (key.equalsIgnoreCase(ARG_ID_USER)) {
+          if (value.equals(ARG_ID_OWNER)) {
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else if (pair.getValue().equals(ARG_ID_NON_UPLOADER)) {
+          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
           } else {
-            accounts = parseAccount(pair.getValue());
+            accounts = parseAccount(value);
           }
-        } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
-          group = parseGroup(pair.getValue()).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
+          group = parseGroup(value).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_COUNT)) {
+          if (!isInt(value)) {
+            throw new QueryParseException("Invalid count argument. Value should be an integer");
+          }
+          count = Integer.parseInt(value);
+          countOp = operator;
+          if (count == 0) {
+            throw new QueryParseException("Argument count=0 is not allowed.");
+          }
+          if (count > LabelPredicate.MAX_COUNT) {
+            throw new QueryParseException(
+                String.format(
+                    "count=%d is not allowed. Maximum allowed value for count is %d.",
+                    count, LabelPredicate.MAX_COUNT));
+          }
         } else {
           throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
@@ -1016,7 +1050,18 @@
       }
     }
 
-    return new LabelPredicate(args, name, accounts, group);
+    return new LabelPredicate(args, name, accounts, group, count, countOp);
+  }
+
+  /** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
+  private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
+      throws QueryParseException {
+    Map<String, ValOp> keyValArgs = labelArgs.keyValue;
+    if (keyValArgs.containsKey(k1) && keyValArgs.containsKey(k2)) {
+      throw new QueryParseException(
+          String.format(
+              "Cannot use the '%s' argument in conjunction with the '%s' argument", k1, k2));
+    }
   }
 
   private static boolean isInt(String s) {
@@ -1339,7 +1384,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1348,7 +1393,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1390,7 +1435,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1399,7 +1444,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 12efecb..b2bc6aa 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -34,17 +35,34 @@
   protected final ProjectCache projectCache;
   protected final PermissionBackend permissionBackend;
   protected final IdentifiedUser.GenericFactory userFactory;
+  /** label name to be matched. */
   protected final String label;
+
+  /** Expected vote value for the label. */
   protected final int expVal;
+
+  /**
+   * Number of times the value {@link #expVal} for label {@link #label} should occur. If null, match
+   * with any count greater or equal to 1.
+   */
+  @Nullable protected final Integer count;
+
+  /** Account ID that has voted on the label. */
   protected final Account.Id account;
+
   protected final AccountGroup.UUID group;
 
   public EqualsLabelPredicate(
-      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+      LabelPredicate.Args args,
+      String label,
+      int expVal,
+      Account.Id account,
+      @Nullable Integer count) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
     this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
+    this.count = count;
     this.group = args.group;
     this.label = label;
     this.expVal = expVal;
@@ -60,6 +78,14 @@
       return false;
     }
 
+    if (Integer.valueOf(0).equals(count)) {
+      // We don't match against count=0 so that the computation is identical to the stored values
+      // in the index. We do that since computing count=0 requires looping on all {label_type,
+      // vote_value} for the change and storing a {count=0} format for it in the change index which
+      // is computationally expensive.
+      return false;
+    }
+
     Optional<ProjectState> project = projectCache.get(c.getDest().project());
     if (!project.isPresent()) {
       // The project has disappeared.
@@ -73,12 +99,13 @@
     }
 
     boolean hasVote = false;
+    int matchingVotes = 0;
     object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
         if (match(object, p.value(), p.accountId())) {
-          return true;
+          matchingVotes += 1;
         }
       }
     }
@@ -87,7 +114,7 @@
       return true;
     }
 
-    return false;
+    return count == null ? matchingVotes >= 1 : matchingVotes == count;
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 5f017fb..2e09075 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.OrPredicate;
@@ -29,9 +29,11 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.IntStream;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
   protected static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_COUNT = 5; // inclusive
 
   protected static class Args {
     protected final ProjectCache projectCache;
@@ -40,6 +42,8 @@
     protected final String value;
     protected final Set<Account.Id> accounts;
     protected final AccountGroup.UUID group;
+    protected final Integer count;
+    protected final PredicateArgs.Operator countOp;
 
     protected Args(
         ProjectCache projectCache,
@@ -47,13 +51,17 @@
         IdentifiedUser.GenericFactory userFactory,
         String value,
         Set<Account.Id> accounts,
-        AccountGroup.UUID group) {
+        AccountGroup.UUID group,
+        @Nullable Integer count,
+        @Nullable PredicateArgs.Operator countOp) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
       this.value = value;
       this.accounts = accounts;
       this.group = group;
+      this.count = count;
+      this.countOp = countOp;
     }
   }
 
@@ -75,19 +83,35 @@
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
-      AccountGroup.UUID group) {
+      AccountGroup.UUID group,
+      @Nullable Integer count,
+      @Nullable PredicateArgs.Operator countOp) {
     super(
         predicates(
-            new Args(a.projectCache, a.permissionBackend, a.userFactory, value, accounts, group)));
+            new Args(
+                a.projectCache,
+                a.permissionBackend,
+                a.userFactory,
+                value,
+                accounts,
+                group,
+                count,
+                countOp)));
     this.value = value;
   }
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
-
+    List<Integer> counts = getCounts(args.count, args.countOp);
     try {
       MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
-      return ImmutableList.of(magicLabelPredicate(args, mlv));
+      List<Predicate<ChangeData>> result = Lists.newArrayListWithCapacity(counts.size());
+      if (counts.isEmpty()) {
+        result.add(magicLabelPredicate(args, mlv, /* count= */ null));
+      } else {
+        counts.forEach(count -> result.add(magicLabelPredicate(args, mlv, count)));
+      }
+      return result;
     } catch (IllegalArgumentException e) {
       // Try next format.
     }
@@ -123,16 +147,24 @@
     int min = range.min;
     int max = range.max;
 
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
+    List<Predicate<ChangeData>> r =
+        Lists.newArrayListWithCapacity((counts.isEmpty() ? 1 : counts.size()) * (max - min + 1));
     for (int i = min; i <= max; i++) {
-      r.add(onePredicate(args, prefix, i));
+      if (counts.isEmpty()) {
+        r.add(onePredicate(args, prefix, i, /* count= */ null));
+      } else {
+        for (int count : counts) {
+          r.add(onePredicate(args, prefix, i, count));
+        }
+      }
     }
     return r;
   }
 
-  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (expVal != 0) {
-      return equalsLabelPredicate(args, label, expVal);
+      return equalsLabelPredicate(args, label, expVal, count);
     }
     return noLabelQuery(args, label);
   }
@@ -140,34 +172,66 @@
   protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
-      r.add(equalsLabelPredicate(args, label, i));
-      r.add(equalsLabelPredicate(args, label, -i));
+      r.add(equalsLabelPredicate(args, label, i, /* count= */ null));
+      r.add(equalsLabelPredicate(args, label, -i, /* count= */ null));
     }
     return not(or(r));
   }
 
-  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new EqualsLabelPredicate(args, label, expVal, null);
+      return new EqualsLabelPredicate(args, label, expVal, null, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new EqualsLabelPredicate(args, label, expVal, a));
+      r.add(new EqualsLabelPredicate(args, label, expVal, a, count));
     }
     return or(r);
   }
 
-  protected static Predicate<ChangeData> magicLabelPredicate(Args args, MagicLabelVote mlv) {
+  protected static Predicate<ChangeData> magicLabelPredicate(
+      Args args, MagicLabelVote mlv, @Nullable Integer count) {
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new MagicLabelPredicate(args, mlv, /* account= */ null);
+      return new MagicLabelPredicate(args, mlv, /* account= */ null, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new MagicLabelPredicate(args, mlv, a));
+      r.add(new MagicLabelPredicate(args, mlv, a, count));
     }
     return or(r);
   }
 
+  private static List<Integer> getCounts(
+      @Nullable Integer count, @Nullable PredicateArgs.Operator countOp) {
+    List<Integer> result = new ArrayList<>();
+    if (count == null) {
+      return result;
+    }
+    switch (countOp) {
+      case EQUAL:
+      case GREATER_EQUAL:
+      case LESS_EQUAL:
+        result.add(count);
+        break;
+      default:
+        break;
+    }
+    switch (countOp) {
+      case GREATER:
+      case GREATER_EQUAL:
+        IntStream.range(count + 1, MAX_COUNT + 1).forEach(result::add);
+        break;
+      case LESS:
+      case LESS_EQUAL:
+        IntStream.range(0, count).forEach(result::add);
+        break;
+      default:
+        break;
+    }
+    return result;
+  }
+
   @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
index 3917c79..5a81ca1 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -30,13 +31,21 @@
   protected final LabelPredicate.Args args;
   private final MagicLabelVote magicLabelVote;
   private final Account.Id account;
+  @Nullable private final Integer count;
 
   public MagicLabelPredicate(
-      LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
-    super(ChangeField.LABEL, magicLabelVote.formatLabel());
+      LabelPredicate.Args args,
+      MagicLabelVote magicLabelVote,
+      Account.Id account,
+      @Nullable Integer count) {
+    super(
+        ChangeField.LABEL,
+        ChangeField.formatLabel(
+            magicLabelVote.label(), magicLabelVote.value().name(), account, count));
     this.account = account;
     this.args = args;
     this.magicLabelVote = magicLabelVote;
+    this.count = count;
   }
 
   @Override
@@ -87,7 +96,7 @@
   }
 
   private EqualsLabelPredicate numericPredicate(String label, short value) {
-    return new EqualsLabelPredicate(args, label, value, account);
+    return new EqualsLabelPredicate(args, label, value, account, count);
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index d82b9bc..9f0dffb 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * This class is used to extract comma separated values in a predicate.
@@ -30,8 +33,35 @@
  * appear in the map and others in the positional list (e.g. "vote=approved,jb_2.3).
  */
 public class PredicateArgs {
+  private static final Pattern SPLIT_PATTERN = Pattern.compile("(>|>=|=|<|<=)([^=].*)$");
+
   public List<String> positional;
-  public Map<String, String> keyValue;
+  public Map<String, ValOp> keyValue;
+
+  enum Operator {
+    EQUAL("="),
+    GREATER_EQUAL(">="),
+    GREATER(">"),
+    LESS_EQUAL("<="),
+    LESS("<");
+
+    final String op;
+
+    Operator(String op) {
+      this.op = op;
+    }
+  };
+
+  @AutoValue
+  public abstract static class ValOp {
+    abstract String value();
+
+    abstract Operator operator();
+
+    static ValOp create(String value, Operator operator) {
+      return new AutoValue_PredicateArgs_ValOp(value, operator);
+    }
+  }
 
   /**
    * Parses query arguments into {@link #keyValue} and/or {@link #positional}..
@@ -46,19 +76,39 @@
     keyValue = new HashMap<>();
 
     for (String arg : Splitter.on(',').split(args)) {
-      List<String> splitKeyValue = Splitter.on('=').splitToList(arg);
+      Matcher m = SPLIT_PATTERN.matcher(arg);
 
-      if (splitKeyValue.size() == 1) {
-        positional.add(splitKeyValue.get(0));
-      } else if (splitKeyValue.size() == 2) {
-        if (!keyValue.containsKey(splitKeyValue.get(0))) {
-          keyValue.put(splitKeyValue.get(0), splitKeyValue.get(1));
+      if (!m.find()) {
+        positional.add(arg);
+      } else if (m.groupCount() == 2) {
+        String key = arg.substring(0, m.start());
+        String op = m.group(1);
+        String val = m.group(2);
+        if (!keyValue.containsKey(key)) {
+          keyValue.put(key, ValOp.create(val, getOperator(op)));
         } else {
-          throw new QueryParseException("Duplicate key " + splitKeyValue.get(0));
+          throw new QueryParseException("Duplicate key " + key);
         }
       } else {
-        throw new QueryParseException("invalid arg " + arg);
+        throw new QueryParseException("Invalid arg " + arg);
       }
     }
   }
+
+  private Operator getOperator(String operator) {
+    switch (operator) {
+      case "<":
+        return Operator.LESS;
+      case "<=":
+        return Operator.LESS_EQUAL;
+      case "=":
+        return Operator.EQUAL;
+      case ">=":
+        return Operator.GREATER_EQUAL;
+      case ">":
+        return Operator.GREATER;
+      default:
+        throw new IllegalArgumentException("Invalid Operator " + operator);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index f511683..b76d5cb 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -32,6 +33,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -215,6 +217,33 @@
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
+  @Test
+  public void byPureRevert() throws Exception {
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "is:pure-revert",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).revert().get();
+    String revertId = Integer.toString(changeInfo._number);
+    ChangeData revertChangeData =
+        changeQueryProvider.get().byLegacyChangeId(Change.Id.parse(revertId)).get(0);
+    result = evaluator.evaluateRequirement(sr, revertChangeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3b671aa..5253a5b 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -40,6 +41,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -48,6 +52,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -141,7 +146,6 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
@@ -1038,6 +1042,7 @@
     ChangeInserter ins3 = newChange(repo);
     ChangeInserter ins4 = newChange(repo);
     ChangeInserter ins5 = newChange(repo);
+    ChangeInserter ins6 = newChange(repo);
 
     Change reviewMinus2Change = insert(repo, ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
@@ -1050,7 +1055,13 @@
     Change reviewPlus1Change = insert(repo, ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewPlus2Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert(repo, ins5);
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(createAccount("user1")));
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(userId));
+
+    Change reviewPlus2Change = insert(repo, ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1061,8 +1072,10 @@
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
-    Map<Integer, Change> changes = new LinkedHashMap<>(5);
+    Multimap<Integer, Change> changes =
+        Multimaps.newListMultimap(Maps.newLinkedHashMap(), () -> Lists.newArrayList());
     changes.put(2, reviewPlus2Change);
+    changes.put(1, reviewTwoPlus1Change);
     changes.put(1, reviewPlus1Change);
     changes.put(0, noLabelChange);
     changes.put(-1, reviewMinus1Change);
@@ -1074,9 +1087,9 @@
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
     assertQuery("label:Code-Review-1", reviewMinus1Change);
     assertQuery("label:Code-Review=0", noLabelChange);
-    assertQuery("label:Code-Review=+1", reviewPlus1Change);
-    assertQuery("label:Code-Review=1", reviewPlus1Change);
-    assertQuery("label:Code-Review+1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review+1", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
@@ -1084,6 +1097,7 @@
     assertQuery(
         "label:Code-Review=ANY",
         reviewPlus2Change,
+        reviewTwoPlus1Change,
         reviewPlus1Change,
         reviewMinus1Change,
         reviewMinus2Change);
@@ -1112,14 +1126,70 @@
     assertQuery("label:Code-Review<-2");
 
     assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
     assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
+
+    // count=0 is not allowed
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=+2,count=0"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Argument count=0 is not allowed.");
+    assertQuery("label:Code-Review=1,count=1", reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>1", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=3");
+    thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=1,count=7"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("count=7 is not allowed. Maximum allowed value for count is 5.");
+
+    // Less than operator does not match with changes having count=0 for a specific vote value (i.e.
+    // no votes for that specific value). We do that deliberately since the computation of count=0
+    // for label values is expensive when the change is re-indexed. This is because the operation
+    // requires generating all formats for all {label-type, vote}=0 values that are non-voted for
+    // the change and storing them with the 'count=0' format.
+    assertQuery("label:Code-Review=1,count<5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=1", // reviewTwoPlus1Change is not matched since its count=2
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=1 label:Code-Review=1,count>=1", reviewPlus1Change);
+
+    assertQuery("label:Code-Review=MAX,count=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX,count=2");
+    assertQuery("label:Code-Review=MIN,count=1", reviewMinus2Change);
+    assertQuery("label:Code-Review=MIN,count>1");
+    assertQuery("label:Code-Review=MAX,count<2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1");
+    assertQuery("label:Code-Review=MAX,count<2 label:Code-Review=MAX,count>=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1 label:Code-Review=MIN,count>=1");
+    assertQuery("label:Code-Review>=+1,count=2", reviewTwoPlus1Change);
+
+    // "count" and "user" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class,
+        () -> assertQuery("label:Code-Review=+1,user=non_uploader,count=2"));
+
+    // "count" and "group" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
   }
 
   @Test
@@ -1224,16 +1294,15 @@
     assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
   }
 
-  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
-    int size = 0;
-    Change[] range = new Change[end - start + 1];
-    for (int i : changes.keySet()) {
+  private Change[] codeReviewInRange(Multimap<Integer, Change> changes, int start, int end) {
+    List<Change> range = new ArrayList<>();
+    for (Map.Entry<Integer, Change> entry : changes.entries()) {
+      int i = entry.getKey();
       if (i >= start && i <= end) {
-        range[size] = changes.get(i);
-        size++;
+        range.add(entry.getValue());
       }
     }
-    return range;
+    return range.toArray(new Change[0]);
   }
 
   private String createGroup(String name, String owner) throws Exception {
@@ -3891,6 +3960,33 @@
   }
 
   @Test
+  public void isPureRevert() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    // Create two commits and revert second commit (initial commit can't be reverted)
+    Change initial = insert(repo, newChange(repo));
+    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(initial.getChangeId()).current().submit();
+
+    ChangeInfo changeToRevert =
+        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToRevert.id).current().submit();
+
+    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
+    Change.Id changeThatRevertsId = Change.id(changeThatReverts._number);
+    assertQueryByIds("is:pure-revert", changeThatRevertsId);
+
+    // Update the change that reverts such that it's not a pure revert
+    gApi.changes()
+        .id(changeThatReverts.id)
+        .edit()
+        .modifyFile("some-file.txt", RawInputUtil.create("newcontent".getBytes(UTF_8)));
+    gApi.changes().id(changeThatReverts.id).edit().publish();
+    assertQueryByIds("is:pure-revert");
+  }
+
+  @Test
   public void selfFailsForAnonymousUser() throws Exception {
     for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
       assertQuery(query);
diff --git a/plugins/package.json b/plugins/package.json
index 4e3c376..e5d245c 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -6,7 +6,7 @@
       "@polymer/decorators": "^3.0.0",
       "@polymer/polymer": "^3.4.1",
       "@gerritcodereview/typescript-api": "3.4.4",
-      "lit": "2.0.0-rc.3"
+      "lit": "^2.0.2"
     },
     "license": "Apache-2.0",
     "private": true
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 3ff1cc4..4cbe489 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -7,10 +7,10 @@
   resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
   integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
 
-"@lit/reactive-element@^1.0.0-rc.2":
-  version "1.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.0-rc.2.tgz#f24dba16ea571a08dca70f1783bd2ca5ec8de3ee"
-  integrity sha512-cujeIl5Ei8FC7UHf4/4Q3bRJOtdTe1vpJV/JEBYCggedmQ+2P8A2oz7eE+Vxi6OJ4nc0X+KZxXnBoH4QrEbmEQ==
+"@lit/reactive-element@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
+  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
 
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
@@ -26,43 +26,36 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
-"@types/trusted-types@^1.0.1":
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da"
-  integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
 "@webcomponents/shadycss@^1.9.1":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
-lit-element@^3.0.0-rc.2:
-  version "3.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.0-rc.2.tgz#883d0b6fd7b846226d360699d1b713da5fc7e1b7"
-  integrity sha512-2Z7DabJ3b5K+p5073vFjMODoaWqy5PIaI4y6ADKm+fCGc8OnX9fU9dMoUEBZjFpd/bEFR9PBp050tUtBnT9XTQ==
+lit-element@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
+  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
   dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-html "^2.0.0-rc.3"
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
 
-lit-html@^2.0.0-rc.3:
-  version "2.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.0-rc.3.tgz#1c216e548630e18d3093d97f4e29563abce659af"
-  integrity sha512-Y6P8LlAyQuqvzq6l/Nc4z5/P5M/rVLYKQIRxcNwSuGajK0g4kbcBFQqZmgvqKG+ak+dHZjfm2HUw9TF5N/pkCw==
+lit-html@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
+  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
   dependencies:
-    "@types/trusted-types" "^1.0.1"
+    "@types/trusted-types" "^2.0.2"
 
-lit-html@^2.0.0-rc.4:
-  version "2.0.0-rc.4"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.0-rc.4.tgz#1015fa8f1f7c8c5b79999ed0bc11c3b79ff1aab5"
-  integrity sha512-WSLGu3vxq7y8q/oOd9I3zxyBELNLLiDk6gAYoKK4PGctI5fbh6lhnO/jVBdy0PV/vTc+cLJCA/occzx3YoNPeg==
+lit@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
   dependencies:
-    "@types/trusted-types" "^1.0.1"
-
-lit@2.0.0-rc.3:
-  version "2.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.0-rc.3.tgz#8b6a85268aba287c11125dfe57e88e0bc09beaff"
-  integrity sha512-UZDLWuspl7saA+WvS0e+TE3NdGGE05hOIwUPTWiibs34c5QupcEzpjB/aElt79V9bELQVNbUUwa0Ow7D1Wuszw==
-  dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-element "^3.0.0-rc.2"
-    lit-html "^2.0.0-rc.4"
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 39b40b6..e2b1502 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1065,7 +1065,7 @@
   url: string;
   /** URL to the icon of the link. */
   image_url?: string;
-  /* The links target. */
+  /* Value of the "target" attribute for anchor elements. */
   target?: string;
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index cf1e8ef..cd55e15 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -46,7 +46,8 @@
       width: 100%;
     }
     .comments,
-    .reviewers {
+    .reviewers,
+    .requirements {
       white-space: nowrap;
     }
     .reviewers {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 12cf314..68566f0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -16,9 +16,10 @@
  */
 
 import '../../../styles/gr-change-list-styles';
+import '../../../styles/gr-font-styles';
+import '../../../styles/shared-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index c31da77..77320b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -20,6 +20,9 @@
   <style include="shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="gr-change-list-styles">
     #changeList {
       border-collapse: collapse;
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index ad8f72f..e4b466b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -232,6 +232,9 @@
           height: var(--line-height-small);
           vertical-align: top;
         }
+        .checksChip a iron-icon.launch {
+          color: var(--link-color);
+        }
         .checksChip.error {
           color: var(--error-foreground);
           border-color: var(--error-foreground);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 8aeb082..ca237d1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -623,6 +623,12 @@
 
   private diffViewMode?: DiffViewMode;
 
+  /**
+   * If the user comes back to the change page we want to remember the scroll
+   * position when we re-render the page as is.
+   */
+  private scrollPosition?: number;
+
   override ready() {
     super.ready();
     aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
@@ -711,6 +717,7 @@
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
 
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
@@ -729,6 +736,7 @@
       'visibilitychange',
       this.handleVisibilityChange
     );
+    document.removeEventListener('scroll', this.handleScroll);
     this.replyRefitTask?.cancel();
     this.scrollTask?.cancel();
 
@@ -746,6 +754,15 @@
     return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
   }
 
+  private readonly handleScroll = () => {
+    if (!this.isViewCurrent) return;
+    this.scrollTask = debounce(
+      this.scrollTask,
+      () => (this.scrollPosition = document.documentElement.scrollTop),
+      150
+    );
+  };
+
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
     this.$.applyFixDialog.open(e);
   }
@@ -1251,6 +1268,7 @@
       // If there is no change in patchset or changeNum, such as when user goes
       // to the diff view and then comes back to change page then there is no
       // need to reload anything and we render the change view component as is.
+      document.documentElement.scrollTop = this.scrollPosition ?? 0;
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 760e211..962ccef 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -37,6 +37,7 @@
 import {Execution} from '../../../constants/reporting';
 import {ChangeStatus} from '../../../constants/constants';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends LitElement {
@@ -55,6 +56,7 @@
 
   static override get styles() {
     return [
+      fontStyles,
       css`
         .scoresTable {
           display: table;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index e4703df..744db3b 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -35,6 +35,9 @@
   href?: string;
 
   @property()
+  label?: string;
+
+  @property()
   showSubmittableCheck = false;
 
   @property()
@@ -110,7 +113,12 @@
     const linkClass = this._computeLinkClass(change);
     return html`
       <div class="changeContainer">
-        <a href="${ifDefined(this.href)}" class="${linkClass}"><slot></slot></a>
+        <a
+          href="${ifDefined(this.href)}"
+          aria-label="${ifDefined(this.label)}"
+          class="${linkClass}"
+          ><slot></slot
+        ></a>
         ${this.showSubmittableCheck
           ? html`<span
               tabindex="-1"
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 7493e2f..963c009 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -33,6 +33,7 @@
 import {appContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
 import {
   changeIsOpen,
@@ -146,6 +147,45 @@
       this.conflictingChanges.length,
       this.cherryPickChanges.length
     );
+
+    const sectionRenderers = [
+      this.renderRelationChain,
+      this.renderSubmittedTogether,
+      this.renderSameTopic,
+      this.renderMergeConflicts,
+      this.renderCherryPicks,
+    ];
+
+    let firstNonEmptySectionFound = false;
+    const sections = [];
+    for (const renderer of sectionRenderers) {
+      const section: TemplateResult<1> | undefined = renderer.call(
+        this,
+        !firstNonEmptySectionFound,
+        sectionSize
+      );
+      firstNonEmptySectionFound = firstNonEmptySectionFound || !!section;
+      sections.push(section);
+    }
+
+    return html`<gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      ${sections}
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>`;
+  }
+
+  private renderRelationChain(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (this.relatedChanges.length === 0) {
+      return undefined;
+    }
     const relatedChangesMarkersPredicate = this.markersPredicateFactory(
       this.relatedChanges.length,
       this.relatedChanges.findIndex(relatedChange =>
@@ -158,17 +198,11 @@
       this.patchNum,
       this.relatedChanges
     );
-    let firstNonEmptySectionFound = false;
-    let isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.relatedChanges.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const relatedChangeSection = html` <section
-      id="relatedChanges"
-      ?hidden=${!this.relatedChanges.length}
-    >
+
+    return html`<section id="relatedChanges">
       <gr-related-collapse
         title="Relation chain"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
@@ -200,8 +234,19 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderSubmittedTogether(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
     const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
+    if (
+      !submittedTogetherChanges.length &&
+      !this.submittedTogether?.non_visible_changes
+    ) {
+      return undefined;
+    }
     const countNonVisibleChanges =
       this.submittedTogether?.non_visible_changes ?? 0;
     const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
@@ -211,19 +256,10 @@
       ),
       sectionSize(Section.SUBMITTED_TOGETHER)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound &&
-      (!!submittedTogetherChanges?.length ||
-        !!this.submittedTogether?.non_visible_changes);
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const submittedTogetherSection = html`<section
-      id="submittedTogether"
-      ?hidden=${!submittedTogetherChanges?.length &&
-      !this.submittedTogether?.non_visible_changes}
-    >
+    return html`<section id="submittedTogether">
       <gr-related-collapse
         title="Submitted together"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
       >
@@ -239,14 +275,14 @@
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
+                .label="${this.renderChangeTitle(change)}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 .showSubmittableCheck=${true}
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
+                >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
         )}
@@ -255,22 +291,25 @@
         (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
       </div>
     </section>`;
+  }
+
+  private renderSameTopic(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.sameTopicChanges?.length) {
+      return undefined;
+    }
 
     const sameTopicMarkersPredicate = this.markersPredicateFactory(
       this.sameTopicChanges.length,
       -1,
       sectionSize(Section.SAME_TOPIC)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.sameTopicChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const sameTopicSection = html`<section
-      id="sameTopic"
-      ?hidden=${!this.sameTopicChanges?.length}
-    >
+    return html`<section id="sameTopic">
       <gr-related-collapse
         title="Same topic"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
       >
@@ -287,33 +326,35 @@
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
                 .change="${change}"
+                .label="${this.renderChangeTitle(change)}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
+                >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderMergeConflicts(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.conflictingChanges?.length) {
+      return undefined;
+    }
     const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
       this.conflictingChanges.length,
       -1,
       sectionSize(Section.MERGE_CONFLICTS)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.conflictingChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const mergeConflictsSection = html`<section
-      id="mergeConflicts"
-      ?hidden=${!this.conflictingChanges?.length}
-    >
+    return html`<section id="mergeConflicts">
       <gr-related-collapse
         title="Merge conflicts"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
       >
@@ -340,22 +381,24 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderCherryPicks(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.cherryPickChanges.length) {
+      return undefined;
+    }
     const cherryPicksMarkersPredicate = this.markersPredicateFactory(
       this.cherryPickChanges.length,
       -1,
       sectionSize(Section.CHERRY_PICKS)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.cherryPickChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const cherryPicksSection = html`<section
-      id="cherryPicks"
-      ?hidden=${!this.cherryPickChanges?.length}
-    >
+    return html`<section id="cherryPicks">
       <gr-related-collapse
         title="Cherry picks"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
       >
@@ -382,17 +425,17 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
-    return html`<gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param
-        name="change"
-        .value=${this.change}
-      ></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
-      ${mergeConflictsSection} ${cherryPicksSection}
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>`;
+  private renderChangeTitle(change: ChangeInfo) {
+    return `${change.project}: ${change.branch}: ${change.subject}`;
+  }
+
+  private renderChangeLine(change: ChangeInfo) {
+    const truncatedRepo = truncatePath(change.project, 2);
+    return html`<span class="truncatedRepo" .title="${change.project}"
+        >${truncatedRepo}</span
+      >: ${change.branch}: ${change.subject}`;
   }
 
   sectionSizeFactory(
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index a6dc338f..d0b56fb 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -30,6 +30,7 @@
   createSubmittedTogetherInfo,
 } from '../../../test/test-data-generators';
 import {
+  query,
   queryAndAssert,
   resetPlugins,
   stubRestApi,
@@ -227,11 +228,8 @@
         Promise.resolve(submittedTogether)
       );
       await element.reload();
-      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
-        'gr-related-collapse'
-      );
-      assert.isFalse(relatedChanges!.classList.contains('first'));
+      const relatedChanges = query<HTMLElement>(element, '#relatedChanges');
+      assert.notExists(relatedChanges);
       const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#submittedTogether'),
         'gr-related-collapse'
@@ -255,11 +253,11 @@
         'gr-related-collapse'
       );
       assert.isTrue(relatedChanges!.classList.contains('first'));
-      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
-        'gr-related-collapse'
+      const submittedTogetherSection = query<HTMLElement>(
+        element,
+        '#submittedTogether'
       );
-      assert.isFalse(submittedTogetherSection!.classList.contains('first'));
+      assert.notExists(submittedTogetherSection);
       const cherryPicks = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#cherryPicks'),
         'gr-related-collapse'
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 2992923..6406e3c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -159,25 +159,8 @@
           </tr>
         </thead>
         <tbody>
-          ${submit_requirements.map(
-            requirement => html`<tr
-              id="requirement-${charsOnly(requirement.name)}"
-            >
-              <td>${this.renderStatus(requirement.status)}</td>
-              <td class="name">
-                <gr-limited-text
-                  class="name"
-                  limit="25"
-                  .text="${requirement.name}"
-                ></gr-limited-text>
-              </td>
-              <td>
-                <div class="votes-cell">
-                  ${this.renderVotes(requirement)}
-                  ${this.renderChecks(requirement)}
-                </div>
-              </td>
-            </tr>`
+          ${submit_requirements.map(requirement =>
+            this.renderRequirement(requirement)
           )}
         </tbody>
       </table>
@@ -195,6 +178,37 @@
       ${this.renderTriggerVotes()}`;
   }
 
+  renderRequirement(requirement: SubmitRequirementResultInfo) {
+    return html`
+      <tr id="requirement-${charsOnly(requirement.name)}">
+        <td>${this.renderStatus(requirement.status)}</td>
+        <td class="name">
+          <gr-limited-text
+            class="name"
+            limit="25"
+            .text="${requirement.name}"
+          ></gr-limited-text>
+        </td>
+        <td>
+          <gr-endpoint-decorator
+            class="votes-cell"
+            name="${`submit-requirement-${charsOnly(requirement.name)}`}"
+          >
+            <gr-endpoint-param
+              name="change"
+              .value=${this.change}
+            ></gr-endpoint-param>
+            <gr-endpoint-param
+              name="requirement"
+              .value=${requirement}
+            ></gr-endpoint-param>
+            ${this.renderVotes(requirement)}${this.renderChecks(requirement)}
+          </gr-endpoint-decorator>
+        </td>
+      </tr>
+    `;
+  }
+
   renderStatus(status: SubmitRequirementStatus) {
     const icon = iconForStatus(status);
     return html`<iron-icon
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index f5072b9..a6176e1 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -41,7 +41,6 @@
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -109,10 +108,7 @@
   constructor() {
     super();
     this.restApiService.getPreferences().then(prefs => {
-      if (
-        !prefs?.disable_token_highlighting &&
-        appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
-      ) {
+      if (!prefs?.disable_token_highlighting) {
         this.layers = [new TokenHighlightLayer(this)];
       }
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
index de7d007..54b2450f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -198,7 +198,6 @@
       element,
     } = this.findTokenAncestor(e?.target);
     if (!newHighlight || newHighlight === this.currentHighlight) return;
-    if (this.countOccurrences(newHighlight) <= 1) return;
     this.hoveredElement = element;
     this.updateTokenTask = debounce(
       this.updateTokenTask,
@@ -247,13 +246,6 @@
     return this.findTokenAncestor(el.parentElement);
   }
 
-  countOccurrences(token: string | undefined) {
-    if (!token) return 0;
-    const linesLeft = this.tokenToLinesLeft.get(token);
-    const linesRight = this.tokenToLinesRight.get(token);
-    return (linesLeft?.size ?? 0) + (linesRight?.size ?? 0);
-  }
-
   private updateTokenHighlight(
     newHighlight: string | undefined,
     newLineNumber: number,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
index 2993d35..a0670b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -290,6 +290,34 @@
       assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
     });
 
+    test('triggers listener on token with single occurrence', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('a tokenWithSingleOccurence');
+      const line2 = createLine('can be highlighted', 2);
+      annotate(line1);
+      annotate(line2, Side.RIGHT, 2);
+      const tokenNode = queryAndAssert(line1, '.tk-tokenWithSingleOccurence');
+      assert.isTrue(tokenNode.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(tokenNode),
+        tokenNode
+      );
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'tokenWithSingleOccurence',
+        side: Side.RIGHT,
+        element: tokenNode,
+        range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
+      });
+
+      MockInteractions.click(container);
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+    });
+
     test('clicking clears highlight', async () => {
       const clock = sinon.useFakeTimers();
       const line1 = createLine('two words');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index a828b9c..025e477 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -336,9 +336,7 @@
     const preferencesPromise = appContext.restApiService.getPreferences();
     await getPluginLoader().awaitPluginsLoaded();
     const prefs = await preferencesPromise;
-    const enableTokenHighlight =
-      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING) &&
-      !prefs?.disable_token_highlighting;
+    const enableTokenHighlight = !prefs?.disable_token_highlighting;
 
     assertIsDefined(this.path, 'path');
     this._layers = this.getLayers(this.path, enableTokenHighlight);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 8901636..6facdca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -1304,7 +1304,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1358,7 +1358,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index d2c9de6..a47b685 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -185,7 +185,7 @@
         mobileText: '3',
         bottomText: '',
         value: 3,
-        date: '2021-03-30 01:02:03.000000000' as Timestamp,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
       } as DropdownItem,
       {
         disabled: true,
@@ -194,7 +194,7 @@
         mobileText: '2',
         bottomText: '',
         value: 2,
-        date: '2021-03-30 01:02:03.000000000' as Timestamp,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
       } as DropdownItem,
       {
         disabled: true,
@@ -203,7 +203,7 @@
         mobileText: '1',
         bottomText: '',
         value: 1,
-        date: '2021-03-30 01:02:03.000000000' as Timestamp,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
       } as DropdownItem,
       {
         text: 'Base',
@@ -332,7 +332,7 @@
         mobileText: '3',
         bottomText: '',
         value: 3,
-        date: '2021-03-30 01:02:03.000000000' as Timestamp,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
       } as DropdownItem,
       {
         disabled: false,
@@ -341,7 +341,7 @@
         mobileText: '2 description',
         bottomText: 'description',
         value: 2,
-        date: '2021-03-30 01:02:03.000000000' as Timestamp,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
       } as DropdownItem,
       {
         disabled: true,
@@ -350,7 +350,7 @@
         mobileText: '1',
         bottomText: '',
         value: 1,
-        date: '2021-03-30 01:02:03.000000000' as Timestamp,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
       } as DropdownItem,
     ];
 
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
index dcf7236..8ce8ce2 100644
--- a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
+++ b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
@@ -55,7 +55,7 @@
   override render() {
     const icon = this.icon ?? '';
     return html` <div class="row">
-      <iron-icon class="icon" .icon=${icon}></iron-icon>
+      <iron-icon class="icon" .icon=${icon} aria-hidden="true"></iron-icon>
       <slot></slot>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index dabf761..0283ca4 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -261,7 +261,7 @@
           ${!this.hideStatus && account.status
             ? html`<iron-icon
                 class="status"
-                icon="gr-icons:calendar"
+                icon="gr-icons:unavailable"
               ></iron-icon>`
             : ''}
         </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 246dcd9..7ecced0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -55,7 +55,6 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
 import {
@@ -209,8 +208,6 @@
 
   private readonly reporting = appContext.reportingService;
 
-  private readonly flagsService = appContext.flagsService;
-
   private readonly commentsService = appContext.commentsService;
 
   readonly storage = appContext.storageService;
@@ -377,10 +374,7 @@
   }
 
   _initLayers(disableTokenHighlighting: boolean) {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING) &&
-      !disableTokenHighlighting
-    ) {
+    if (!disableTokenHighlighting) {
       this.layers.push(new TokenHighlightLayer(this));
     }
     this.layers.push(this.syntaxLayer);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 6a34fbb..e2ebb14 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -226,7 +226,7 @@
     return html`
       <div class="status">
         <span class="title">
-          <iron-icon icon="gr-icons:calendar"></iron-icon>
+          <iron-icon icon="gr-icons:unavailable"></iron-icon>
           Status:
         </span>
         <span class="value">${this.account.status}</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 1a6239f..2533e00 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -162,6 +162,8 @@
       <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
       <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:event_busy -->
+      <g id="unavailable"><path d="M0 0h24v24H0z" fill="none"/><path d="M9.31 17l2.44-2.44L14.19 17l1.06-1.06-2.44-2.44 2.44-2.44L14.19 10l-2.44 2.44L9.31 10l-1.06 1.06 2.44 2.44-2.44 2.44L9.31 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 2839874..863f95f 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,7 +25,6 @@
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
-  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 584592e..dd56ce2 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -274,7 +274,7 @@
   [revisionId: string]: RevisionInfo;
 } {
   const revisions: {[revisionId: string]: RevisionInfo} = {};
-  const revisionDate = TEST_CHANGE_CREATED;
+  let revisionDate = TEST_CHANGE_CREATED;
   const revisionIdStart = 1; // The same as getCurrentRevision
   for (let i = 0; i < count; i++) {
     const revisionId = (i + revisionIdStart).toString(16);
@@ -285,6 +285,7 @@
     };
     revisions[revisionId] = revision;
     // advance 1 day
+    revisionDate = new Date(revisionDate);
     revisionDate.setDate(revisionDate.getDate() + 1);
   }
   return revisions;
@@ -298,12 +299,13 @@
 export function createChangeMessages(count: number): ChangeMessageInfo[] {
   const messageIdStart = 1000;
   const messages: ChangeMessageInfo[] = [];
-  const messageDate = TEST_CHANGE_CREATED;
+  let messageDate = TEST_CHANGE_CREATED;
   for (let i = 0; i < count; i++) {
     messages.push({
       ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
     });
+    messageDate = new Date(messageDate);
     messageDate.setDate(messageDate.getDate() + 1);
   }
   return messages;