Merge "Enable -Xlint:unchecked"
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 9afd6e3..c3d79b1 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -15,6 +15,7 @@
 == Contributor Guides
 . link:dev-community.html[Gerrit Community]
 . link:dev-community.html#how-to-contribute[How to Contribute]
+.. link:dev-readme.html[Developer Setup]
 
 == User Guides
 . link:intro-user.html[User Guide]
diff --git a/WORKSPACE b/WORKSPACE
index da15966..779237b 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -136,7 +136,7 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "16.16.0",
+    node_version = "17.9.1",
     yarn_version = "1.22.19",
 )
 
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 850c4a5..278d2af 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.RangeUtil.Range;
 
 public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
   private final Range range;
 
-  protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+  protected IntegerRangePredicate(SchemaField<T, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
     range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index ef69b4c..b6418a9 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -22,11 +22,11 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Arrays;
 import java.util.List;
 import java.util.function.Supplier;
@@ -77,10 +77,10 @@
   }
 
   @SafeVarargs
-  public final Q setRequestedFields(FieldDef<T, ?>... fields) {
+  public final Q setRequestedFields(SchemaField<T, ?>... fields) {
     checkArgument(fields.length > 0, "requested field list is empty");
     queryProcessor.setRequestedFields(
-        Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
+        Arrays.stream(fields).map(SchemaField::getName).collect(toSet()));
     return self();
   }
 
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 29d6f22..1fd81a6 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.json.JavaSqlTimestampHelper;
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -30,7 +30,7 @@
     }
   }
 
-  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
+  protected TimestampRangePredicate(SchemaField<I, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index ce50473..f7b1f2c 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -125,7 +125,7 @@
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
-    } else if (f == ChangeField.MERGED_ON) {
+    } else if (f == ChangeField.MERGED_ON_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 079380e..b85f40e 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -103,7 +103,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
-  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON_SPEC);
   static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
   private static final String CHANGES = "changes";
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 68d3a3e..6f7f452 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -203,18 +203,26 @@
 
   /** When this change was merged, time since January 1, 1970. */
   // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
-  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
-      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+  public static final IndexedField<ChangeData, Timestamp> MERGED_ON_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("MergedOn")
           .stored()
           .build(
               cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
               (cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
 
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec MERGED_ON_SPEC =
+      MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
+
   /** List of full file paths modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> PATH =
+  public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
       // Named for backwards compatibility.
-      exact(ChangeQueryBuilder.FIELD_FILE)
-          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+      IndexedField.<ChangeData>iterableStringBuilder("File")
+          .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
+      PATH_FIELD
+          // Named for backwards compatibility.
+          .exact(ChangeQueryBuilder.FIELD_FILE);
 
   public static Set<String> getFileParts(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -230,24 +238,27 @@
   }
 
   /** Hashtags tied to a change */
-  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      exact(ChangeQueryBuilder.FIELD_HASHTAG)
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
+          .size(200)
+          .build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec HASHTAG_SPEC =
+      HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
 
   /** Hashtags as fulltext field for in-string search. */
-  public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
-      fullText("hashtag2")
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FUZZY_HASHTAG =
+      HASHTAG_FIELD.fullText("hashtag2");
 
   /** Hashtags as prefix field for in-string search. */
-  public static final FieldDef<ChangeData, Iterable<String>> PREFIX_HASHTAG =
-      prefix("hashtag3")
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PREFIX_HASHTAG =
+      HASHTAG_FIELD.prefix("hashtag3");
 
   /** Hashtags with original case. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
-      storedOnly("_hashtag")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("HashtagCaseAware")
+          .stored()
+          .build(
               cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
               (cd, field) ->
                   cd.setHashtags(
@@ -255,6 +266,9 @@
                           .map(f -> new String(f, UTF_8))
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
+
   /** Components of each file path modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
       exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 82f9b87..0878ffa5 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.index.SchemaUtil.schema;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
@@ -35,7 +34,7 @@
   static final Schema<ChangeData> V74 =
       schema(
           /* version= */ 74,
-          ImmutableList.<FieldDef<ChangeData, ?>>of(
+          ImmutableList.of(
               ChangeField.ADDED,
               ChangeField.APPROVAL,
               ChangeField.ASSIGNEE,
@@ -63,10 +62,7 @@
               ChangeField.EXTENSION,
               ChangeField.FILE_PART,
               ChangeField.FOOTER,
-              ChangeField.FUZZY_HASHTAG,
               ChangeField.GROUP,
-              ChangeField.HASHTAG,
-              ChangeField.HASHTAG_CASE_AWARE,
               ChangeField.ID,
               ChangeField.IS_PURE_REVERT,
               ChangeField.IS_SUBMITTABLE,
@@ -74,11 +70,9 @@
               ChangeField.LEGACY_ID_STR,
               ChangeField.MERGE,
               ChangeField.MERGEABLE,
-              ChangeField.MERGED_ON,
               ChangeField.ONLY_EXTENSIONS,
               ChangeField.OWNER,
               ChangeField.PATCH_SET,
-              ChangeField.PATH,
               ChangeField.PENDING_REVIEWER,
               ChangeField.PENDING_REVIEWER_BY_EMAIL,
               ChangeField.PRIVATE,
@@ -103,19 +97,28 @@
               ChangeField.UPLOADER,
               ChangeField.WIP),
           ImmutableList.<IndexedField<ChangeData, ?>>of(
-              ChangeField.SUBMISSIONID_FIELD,
-              ChangeField.STATUS_FIELD,
+              ChangeField.HASHTAG_CASE_AWARE_FIELD,
+              ChangeField.HASHTAG_FIELD,
+              ChangeField.MERGED_ON_FIELD,
+              ChangeField.PATH_FIELD,
               ChangeField.PROJECT_FIELD,
               ChangeField.REF_FIELD,
+              ChangeField.STATUS_FIELD,
+              ChangeField.SUBMISSIONID_FIELD,
               ChangeField.TOPIC_FIELD),
           ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
               ChangeField.EXACT_TOPIC,
+              ChangeField.FUZZY_HASHTAG,
               ChangeField.FUZZY_TOPIC,
-              ChangeField.SUBMISSIONID_SPEC,
-              ChangeField.STATUS_SPEC,
-              ChangeField.PROJECT_SPEC,
+              ChangeField.HASHTAG_CASE_AWARE_SPEC,
+              ChangeField.HASHTAG_SPEC,
+              ChangeField.MERGED_ON_SPEC,
+              ChangeField.PATH_SPEC,
               ChangeField.PROJECTS_SPEC,
-              ChangeField.REF_SPEC));
+              ChangeField.PROJECT_SPEC,
+              ChangeField.REF_SPEC,
+              ChangeField.STATUS_SPEC,
+              ChangeField.SUBMISSIONID_SPEC));
 
   /**
    * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
@@ -125,7 +128,7 @@
   static final Schema<ChangeData> V75 =
       new Schema.Builder<ChangeData>()
           .add(V74)
-          .add(ChangeField.PREFIX_HASHTAG)
+          .addSearchSpecs(ChangeField.PREFIX_HASHTAG)
           .addSearchSpecs(ChangeField.PREFIX_TOPIC)
           .build();
 
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 2514989..d3e3477 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -26,7 +26,7 @@
 public class AfterPredicate extends TimestampRangeChangePredicate {
   protected final Instant cut;
 
-  public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+  public AfterPredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
     super(def, name, value);
     cut = parse(value);
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 5d682fb..e9ddbff 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -26,7 +26,7 @@
 public class BeforePredicate extends TimestampRangeChangePredicate {
   protected final Instant cut;
 
-  public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+  public BeforePredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
     super(def, name, value);
     cut = parse(value);
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 6ca3acc..d6df7e0 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 public class BooleanPredicate extends ChangeIndexPredicate {
-  public BooleanPredicate(FieldDef<ChangeData, String> field) {
+  public BooleanPredicate(SchemaField<ChangeData, String> field) {
     super(field, "1");
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
index d86d366..c69f021 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
@@ -14,18 +14,19 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /**
  * Predicate that is mapped to a field in the change index, with additional filtering done in the
  * {@code match} method.
  */
 public abstract class ChangeIndexPostFilterPredicate extends ChangeIndexPredicate {
-  protected ChangeIndexPostFilterPredicate(FieldDef<ChangeData, ?> def, String value) {
+  protected ChangeIndexPostFilterPredicate(SchemaField<ChangeData, ?> def, String value) {
     super(def, value);
   }
 
-  protected ChangeIndexPostFilterPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+  protected ChangeIndexPostFilterPredicate(
+      SchemaField<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 7dad123..74d7a5c 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -200,14 +200,14 @@
 
   /** Returns a predicate that matches changes that modified the provided {@code path}. */
   public static Predicate<ChangeData> path(String path) {
-    return new ChangeIndexPredicate(ChangeField.PATH, path);
+    return new ChangeIndexPredicate(ChangeField.PATH_SPEC, path);
   }
 
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
   public static Predicate<ChangeData> hashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
     return new ChangeIndexPredicate(
-        ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+        ChangeField.HASHTAG_SPEC, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 3c69fbf..88b235e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -536,16 +536,16 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
+    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
-        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+        ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
+    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
-        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+        ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
index 312c04e..b6059f7 100644
--- a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IntegerRangePredicate;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.QueryParseException;
@@ -22,7 +22,7 @@
 public abstract class IntegerRangeChangePredicate extends IntegerRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
 
-  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type, String value)
+  protected IntegerRangeChangePredicate(SchemaField<ChangeData, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
   }
diff --git a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
index 24efa6a..f62780a 100644
--- a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.change.ChangeField.HASHTAG;
+import static com.google.gerrit.server.index.change.ChangeField.HASHTAG_SPEC;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
@@ -23,7 +23,7 @@
   protected final RunAutomaton pattern;
 
   public RegexHashtagPredicate(String re) {
-    super(HASHTAG, re);
+    super(HASHTAG_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 4c3c04c..9368047 100644
--- a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -19,7 +19,7 @@
 
 public class RegexPathPredicate extends ChangeRegexPredicate {
   public RegexPathPredicate(String re) {
-    super(ChangeField.PATH, re);
+    super(ChangeField.PATH_SPEC, re);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
index abbd0c9..0b2d32d 100644
--- a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.TimestampRangePredicate;
 import java.sql.Timestamp;
@@ -22,7 +22,7 @@
 public abstract class TimestampRangeChangePredicate extends TimestampRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
   protected TimestampRangeChangePredicate(
-      FieldDef<ChangeData, Timestamp> def, String name, String value) {
+      SchemaField<ChangeData, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
new file mode 100644
index 0000000..ff26ff3
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -0,0 +1,411 @@
+// Copyright (C) 2022 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.schema;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A class with logic for migrating existing label functions to submit requirements and resetting
+ * the label functions to {@link LabelFunction#NO_BLOCK}.
+ *
+ * <p>Important note: Callers should do this migration only if this gerrit installation has no
+ * Prolog submit rules (i.e. no rules.pl file in refs/meta/config). Otherwise, the newly created
+ * submit requirements might not behave as intended.
+ *
+ * <p>The conversion is done as follows:
+ *
+ * <ul>
+ *   <li>MaxWithBlock is translated to submittableIf = label:$lbl=MAX AND -label:$lbl=MIN
+ *   <li>MaxNoBlock is translated to submittableIf = label:$lbl=MAX
+ *   <li>AnyWithBlock is translated to submittableIf = -label:$lbl=MIN
+ *   <li>NoBlock/NoOp are translated to applicableIf = is:false (not applicable)
+ *   <li>PatchSetLock labels are left as is
+ * </ul>
+ *
+ * If the label has {@link LabelType#isIgnoreSelfApproval()}, the max vote is appended with the
+ * 'user=non_uploader' argument.
+ *
+ * <p>If there is an existing label and there exists a "submit requirement" with the same name, then
+ * the logic will leave the "submit requirement" as is and will not replace it. But in this case,
+ * the label will be reset to NO_BLOCK anyway.
+ */
+public class MigrateLabelFunctionsToSubmitRequirement {
+  private static final String COMMIT_MSG = "Migrate label functions to submit requirements";
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  public enum Status {
+    /**
+     * The migrator updated the project config and created new submit requirements and/or did reset
+     * label functions.
+     */
+    MIGRATED,
+
+    /** The project had prolog rules, and the migration was skipped. */
+    HAS_PROLOG,
+
+    /**
+     * The project was migrated with a previous run of this class. The migration for this run was
+     * skipped.
+     */
+    PREVIOUSLY_MIGRATED,
+
+    /**
+     * Migration was run for the project but did not update the project.config because it was
+     * up-to-date.
+     */
+    NO_CHANGE
+  }
+
+  @Inject
+  public MigrateLabelFunctionsToSubmitRequirement(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * For each label function, create a corresponding submit-requirement and set the label function
+   * to NO_BLOCK. Blocking label functions are substituted with blocking submit-requirements.
+   * Non-blocking label functions are substituted with non-applicable submit requirements, allowing
+   * the label vote to be surfaced as a trigger vote (optional label).
+   *
+   * @return {@link Status} reflecting the status of the migration.
+   */
+  public Status executeMigration(Project.NameKey project, UpdateUI ui)
+      throws IOException, ConfigInvalidException {
+    if (hasPrologRules(project)) {
+      ui.message(String.format("Skipping project %s because it has prolog rules", project));
+      return Status.HAS_PROLOG;
+    }
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    boolean migrationPerformed = false;
+    try (Repository repo = repoManager.openRepository(project);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
+      if (hasMigrationAlreadyRun(repo)) {
+        ui.message(
+            String.format(
+                "Skipping migrating label functions to submit requirements for project '%s'"
+                    + " because it has been previously migrated",
+                project));
+        return Status.PREVIOUSLY_MIGRATED;
+      }
+      projectConfig.load(project, repo);
+      Config cfg = projectConfig.getConfig();
+      Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg);
+      Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg);
+      boolean updated = false;
+      for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) {
+        String labelName = lt.getKey();
+        LabelAttributes attributes = lt.getValue();
+        if (attributes.function().equals("PatchSetLock")) {
+          // PATCH_SET_LOCK functions should be left as is
+          continue;
+        }
+        // If the function is other than "NoBlock" we want to reset the label function regardless
+        // of whether there exists a "submit requirement".
+        if (!attributes.function().equals("NoBlock")) {
+          updated = true;
+          writeLabelFunction(cfg, labelName, "NoBlock");
+        }
+        SubmitRequirement sr = createSrFromLabelDef(labelName, attributes);
+        // Make the operation idempotent by skipping creating the submit-requirement if one was
+        // already created or previously existed.
+        if (existingSubmitRequirements.containsKey(labelName.toLowerCase(Locale.ROOT))) {
+          if (!sr.equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) {
+            ui.message(
+                String.format(
+                    "Warning: Skipping creating a submit requirement for label '%s'. An existing "
+                        + "submit requirement is already present but its definition is not "
+                        + "identical to the existing label definition.",
+                    labelName));
+          }
+          continue;
+        }
+        updated = true;
+        ui.message(
+            String.format(
+                "Project %s: Creating a submit requirement for label %s", project, labelName));
+        writeSubmitRequirement(cfg, sr);
+      }
+      if (updated) {
+        commit(projectConfig, md);
+        migrationPerformed = true;
+      }
+    }
+    return migrationPerformed ? Status.MIGRATED : Status.NO_CHANGE;
+  }
+
+  /**
+   * Returns a Map containing label names as string in keys along with some of its attributes (that
+   * we need in the migration) like canOverride, ignoreSelfApproval and function in the value.
+   */
+  private Map<String, LabelAttributes> getLabelTypes(Config cfg) {
+    Map<String, LabelAttributes> labelTypes = new HashMap<>();
+    for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+      String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+      boolean canOverride =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_CAN_OVERRIDE,
+              /* defaultValue= */ true);
+      boolean ignoreSelfApproval =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_IGNORE_SELF_APPROVAL,
+              /* defaultValue= */ false);
+      LabelAttributes attributes =
+          LabelAttributes.create(
+              function == null ? "MaxWithBlock" : function, canOverride, ignoreSelfApproval);
+      labelTypes.put(labelName, attributes);
+    }
+    return labelTypes;
+  }
+
+  private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) {
+    if (sr.description().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_DESCRIPTION,
+          sr.description().get());
+    }
+    if (sr.applicabilityExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+          sr.applicabilityExpression().get().expressionString());
+    }
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        sr.submittabilityExpression().expressionString());
+    if (sr.overrideExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+          sr.overrideExpression().get().expressionString());
+    }
+    cfg.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        sr.allowOverrideInChildProjects());
+  }
+
+  private void writeLabelFunction(Config cfg, String labelName, String function) {
+    cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
+  }
+
+  private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+    md.getCommitBuilder().setAuthor(serverUser);
+    md.getCommitBuilder().setCommitter(serverUser);
+    md.setMessage(COMMIT_MSG);
+    projectConfig.commit(md);
+  }
+
+  private static SubmitRequirement createSrFromLabelDef(
+      String labelName, LabelAttributes attributes) {
+    if (!isBlockingOrRequiredLabel(attributes.function())) {
+      return createNonApplicableSr(labelName, attributes.canOverride());
+    }
+    return createBlockingOrRequiredSr(labelName, attributes);
+  }
+
+  private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) {
+    return SubmitRequirement.builder()
+        .setName(labelName)
+        .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+        .setAllowOverrideInChildProjects(canOverride)
+        .build();
+  }
+
+  /**
+   * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
+   * and/or not voted by the min vote, according to the label attributes.
+   */
+  private static SubmitRequirement createBlockingOrRequiredSr(
+      String labelName, LabelAttributes attributes) {
+    SubmitRequirement.Builder builder =
+        SubmitRequirement.builder()
+            .setName(labelName)
+            .setAllowOverrideInChildProjects(attributes.canOverride());
+    String maxPart =
+        String.format("label:%s=MAX", labelName)
+            + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : "");
+    switch (attributes.function()) {
+      case "MaxWithBlock":
+        builder.setSubmittabilityExpression(
+            SubmitRequirementExpression.create(
+                String.format("%s AND -label:%s=MIN", maxPart, labelName)));
+        break;
+      case "AnyWithBlock":
+        builder.setSubmittabilityExpression(
+            SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName)));
+        break;
+      case "MaxNoBlock":
+        builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
+        break;
+      default:
+        break;
+    }
+    return builder.build();
+  }
+
+  private static boolean isBlockingOrRequiredLabel(String function) {
+    return function.equals("AnyWithBlock")
+        || function.equals("MaxWithBlock")
+        || function.equals("MaxNoBlock");
+  }
+
+  public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException {
+    for (Project.NameKey p : allProjects) {
+      if (hasPrologRules(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean hasPrologRules(Project.NameKey project) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader reader = rw.getObjectReader()) {
+      Ref refsConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsConfig == null) {
+        // Project does not have a refs/meta/config and no rules.pl consequently.
+        return false;
+      }
+      RevCommit commit = repo.parseCommit(refsConfig.getObjectId());
+      try (TreeWalk tw = TreeWalk.forPath(reader, "rules.pl", commit.getTree())) {
+        if (tw != null) {
+          return true;
+        }
+      }
+
+      return false;
+    }
+  }
+
+  /**
+   * Returns a map containing submit requirement names in lower name as keys, with {@link
+   * com.google.gerrit.entities.SubmitRequirement} as value.
+   */
+  private Map<String, SubmitRequirement> loadSubmitRequirements(Config rc) {
+    Map<String, SubmitRequirement> allRequirements = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(ProjectConfig.SUBMIT_REQUIREMENT)) {
+      String description =
+          rc.getString(ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_DESCRIPTION);
+      String applicabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String overrideExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION);
+      boolean canInherit =
+          rc.getBoolean(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              false);
+      SubmitRequirement submitRequirement =
+          SubmitRequirement.builder()
+              .setName(name)
+              .setDescription(Optional.ofNullable(description))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+              .setAllowOverrideInChildProjects(canInherit)
+              .build();
+      allRequirements.put(name.toLowerCase(Locale.ROOT), submitRequirement);
+    }
+    return allRequirements;
+  }
+
+  private static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MSG.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  @AutoValue
+  abstract static class LabelAttributes {
+    abstract String function();
+
+    abstract boolean canOverride();
+
+    abstract boolean ignoreSelfApproval();
+
+    static LabelAttributes create(
+        String function, boolean canOverride, boolean ignoreSelfApproval) {
+      return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
+          function, canOverride, ignoreSelfApproval);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
new file mode 100644
index 0000000..7aadc08
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -0,0 +1,383 @@
+// Copyright (C) 2022 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.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement.Status;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.inject.Inject;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement}. */
+@Sandboxed
+public class MigrateLabelFunctionsToSubmitRequirementIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void migrateBlockingLabel_maxWithBlock() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxNoBlock() throws Exception {
+    createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_anyWithBlock() throws Exception {
+    createLabel("Foo", "AnyWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "-label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxWithBlock_withIgnoreSelfApproval() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ true);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxNoBlock_withIgnoreSelfApproval() throws Exception {
+    createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ true);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_NoBlock() throws Exception {
+    createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "is:false",
+        /* submittabilityExpression= */ "is:true",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_NoOp() throws Exception {
+    createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "is:false",
+        /* submittabilityExpression= */ "is:true",
+        /* canOverride= */ true);
+
+    // The NoOp function is converted to NoBlock. Both are same.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_PatchSetLock_doesNothing() throws Exception {
+    createLabel("Foo", "PatchSetLock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+    // No submit requirement created for the patchset lock label function
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertNonExistentSr(/* srName = */ "Foo");
+    assertLabelFunction("Foo", "PatchSetLock");
+  }
+
+  @Test
+  public void migrationIsCommittedWithServerIdent() throws Exception {
+    RevCommit oldMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+
+    RevCommit newMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(newMetaCommit).isNotEqualTo(oldMetaCommit);
+    assertThat(newMetaCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+  }
+
+  @Test
+  public void migrationIsIdempotent() throws Exception {
+    String oldRefsConfigId;
+    try (Repository repo = repoManager.openRepository(project)) {
+      oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+    }
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    // Running the migration causes REFS_CONFIG to change.
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(oldRefsConfigId)
+          .isNotEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+      oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+    }
+
+    // No new SRs will be created. No conflicting submit requirements either since the migration
+    // detects that a previous run was made and skips the migration.
+    updateUI = runMigration(/* expectedResult= */ Status.PREVIOUSLY_MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+    // Running the migration a second time won't update REFS_CONFIG.
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(oldRefsConfigId)
+          .isEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+    }
+  }
+
+  @Test
+  public void migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_matchingWithMigration()
+      throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "label:Foo=MAX AND -label:Foo=MIN", /* canOverride= */ true);
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    // No new submit requirements are created.
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    // No conflicting submit requirements from migration vs. what was previously configured.
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // The existing SR was left as is.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+  }
+
+  @Test
+  public void
+      migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_mismatchingWithMigration()
+          throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "project:" + project, /* canOverride= */ true);
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "project:" + project,
+        /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    // One conflicting submit requirement between migration vs. what was previously configured.
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(1);
+
+    // The existing SR was left as is.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "project:" + project,
+        /* canOverride= */ true);
+  }
+
+  @Test
+  public void migrationResetsBlockingLabel_ifSRAlreadyExists() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "owner:" + admin.email(), /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+
+    // The label function was reset
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  private TestUpdateUI runMigration(Status expectedResult) throws Exception {
+    TestUpdateUI updateUi = new TestUpdateUI();
+    MigrateLabelFunctionsToSubmitRequirement executor =
+        new MigrateLabelFunctionsToSubmitRequirement(repoManager, serverIdent.get());
+    Status status = executor.executeMigration(project, updateUi);
+    assertThat(status).isEqualTo(expectedResult);
+    projectCache.evictAndReindex(project);
+    return updateUi;
+  }
+
+  private void createLabel(String labelName, String function, boolean ignoreSelfApproval)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
+  @CanIgnoreReturnValue
+  private SubmitRequirementApi createSubmitRequirement(
+      String name, String submitExpression, boolean canOverride) throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submitExpression;
+    input.allowOverrideInChildProjects = canOverride;
+    return gApi.projects().name(project.get()).submitRequirement(name).create(input);
+  }
+
+  private void assertLabelFunction(String labelName, String function) throws Exception {
+    LabelDefinitionInfo info = gApi.projects().name(project.get()).label(labelName).get();
+    assertThat(info.function).isEqualTo(function);
+  }
+
+  private void assertNonExistentSr(String srName) {
+    ResourceNotFoundException foo =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("Foo").get());
+    assertThat(foo.getMessage()).isEqualTo("Submit requirement '" + srName + "' does not exist");
+  }
+
+  private void assertExistentSr(
+      String srName,
+      String applicabilityExpression,
+      String submittabilityExpression,
+      boolean canOverride)
+      throws Exception {
+    SubmitRequirementInfo sr = gApi.projects().name(project.get()).submitRequirement(srName).get();
+    assertThat(sr.applicabilityExpression).isEqualTo(applicabilityExpression);
+    assertThat(sr.submittabilityExpression).isEqualTo(submittabilityExpression);
+    assertThat(sr.allowOverrideInChildProjects).isEqualTo(canOverride);
+  }
+
+  private static class TestUpdateUI implements UpdateUI {
+    int existingSrsMismatchingWithMigration = 0;
+    int newlyCreatedSrs = 0;
+
+    @Override
+    public void message(String message) {
+      if (message.startsWith("Warning")) {
+        existingSrsMismatchingWithMigration += 1;
+      } else if (message.startsWith("Project")) {
+        newlyCreatedSrs += 1;
+      }
+    }
+
+    @Override
+    public boolean yesno(boolean defaultValue, String message) {
+      return false;
+    }
+
+    @Override
+    public void waitForUser() {}
+
+    @Override
+    public String readString(String defaultValue, Set<String> allowedValues, String message) {
+      return null;
+    }
+
+    @Override
+    public boolean isBatch() {
+      return false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 71695f3..04f9827 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -45,6 +45,7 @@
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.Base64;
+import java.util.Collection;
 import java.util.Optional;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
@@ -72,8 +73,6 @@
 
   @Mock private AccountCache accountCache;
 
-  @Mock private AccountState accountState;
-
   @Mock private AccountManager accountManager;
 
   @Mock private AuthConfig authConfig;
@@ -105,12 +104,8 @@
     authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
     pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
 
-    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
     authSuccessful =
         new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
-    doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
-    doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
-    doReturn(account).when(accountState).account();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
 
     doReturn(new WebSessionManager.Key(AUTH_COOKIE_VALUE)).when(webSessionManager).createKey(any());
@@ -123,6 +118,7 @@
 
   @Test
   public void shouldAllowAnonymousRequest() throws Exception {
+    initAccount();
     initMockedWebSession();
     res.setStatus(HttpServletResponse.SC_OK);
 
@@ -143,6 +139,7 @@
 
   @Test
   public void shouldRequestAuthenticationForBasicAuthRequest() throws Exception {
+    initAccount();
     initMockedWebSession();
     req.addHeader("Authorization", "Basic " + AUTH_USER_B64);
     res.setStatus(HttpServletResponse.SC_OK);
@@ -165,6 +162,7 @@
 
   @Test
   public void shouldAuthenticateSucessfullyAgainstRealmAndReturnCookie() throws Exception {
+    initAccount();
     initWebSessionWithoutCookie();
     requestBasicAuth(req);
     res.setStatus(HttpServletResponse.SC_OK);
@@ -191,9 +189,10 @@
 
   @Test
   public void shouldValidateUserPasswordAndNotReturnCookie() throws Exception {
+    ExternalId extId = createUsernamePasswordExternalId();
+    initAccount(ImmutableSet.of(extId));
     initWebSessionWithoutCookie();
     requestBasicAuth(req);
-    initMockedUsernamePasswordExternalId();
     doReturn(GitBasicAuthPolicy.HTTP).when(authConfig).getGitBasicAuthPolicy();
     res.setStatus(HttpServletResponse.SC_OK);
 
@@ -217,6 +216,7 @@
 
   @Test
   public void shouldNotReauthenticateForGitPostRequest() throws Exception {
+    initAccount();
     req.setPathInfo("/a/project.git/git-upload-pack");
     req.setMethod("POST");
     req.addHeader("Content-Type", "application/x-git-upload-pack-request");
@@ -229,6 +229,7 @@
 
   @Test
   public void shouldReauthenticateForRegularRequestEvenIfAlreadySignedIn() throws Exception {
+    initAccount();
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
     doFilterForRequestWhenAlreadySignedIn();
 
@@ -239,6 +240,7 @@
 
   @Test
   public void shouldReauthenticateEvenIfHasExistingCookie() throws Exception {
+    initAccount();
     initWebSessionWithCookie("GerritAccount=" + AUTH_COOKIE_VALUE);
     res.setStatus(HttpServletResponse.SC_OK);
     requestBasicAuth(req);
@@ -262,6 +264,7 @@
 
   @Test
   public void shouldFailedAuthenticationAgainstRealm() throws Exception {
+    initAccount();
     initMockedWebSession();
     requestBasicAuth(req);
 
@@ -285,6 +288,17 @@
     assertThat(res.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
   }
 
+  private void initAccount() throws Exception {
+    initAccount(ImmutableSet.of());
+  }
+
+  private void initAccount(Collection<ExternalId> extIds) throws Exception {
+    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
+    AccountState accountState = AccountState.forAccount(account, extIds);
+    doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
+    doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
+  }
+
   private void doFilterForRequestWhenAlreadySignedIn()
       throws IOException, ServletException, AccountException {
     initMockedWebSession();
@@ -322,14 +336,12 @@
     doReturn(webSession).when(webSessionItem).get();
   }
 
-  private void initMockedUsernamePasswordExternalId() {
-    ExternalId extId =
-        extIdFactory.createWithPassword(
-            extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
-            AUTH_ACCOUNT_ID,
-            null,
-            AUTH_PASSWORD);
-    doReturn(ImmutableSet.builder().add(extId).build()).when(accountState).externalIds();
+  private ExternalId createUsernamePasswordExternalId() {
+    return extIdFactory.createWithPassword(
+        extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
+        AUTH_ACCOUNT_ID,
+        null,
+        AUTH_PASSWORD);
   }
 
   private void requestBasicAuth(FakeHttpServletRequest fakeReq) {
diff --git a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
index 96919be..7bdb23c 100644
--- a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -22,6 +23,7 @@
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import java.io.IOException;
+import java.time.Instant;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -41,10 +43,12 @@
   private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
 
+  private final AccountState updater =
+      AccountState.forAccount(Account.builder(Account.id(1), Instant.now()).build());
+
   @Mock GitReferenceUpdatedListener refUpdatedListener;
   @Mock GitBatchRefUpdateListener batchRefUpdateListener;
   @Mock EventUtil util;
-  @Mock AccountState updater;
 
   @Before
   public void setup() {
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 537867a..2aa9ca4 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -42,9 +42,11 @@
   static final Schema<ChangeData> V2 =
       schema(
           2,
-          ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.PATH, ChangeField.UPDATED),
-          ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.STATUS_FIELD),
-          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.STATUS_SPEC));
+          ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.UPDATED),
+          ImmutableList.<IndexedField<ChangeData, ?>>of(
+              ChangeField.PATH_FIELD, ChangeField.STATUS_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+              ChangeField.PATH_SPEC, ChangeField.STATUS_SPEC));
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 9be7772..d727ba1 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -2015,7 +2015,7 @@
 
   @Test
   public void byMergedBefore() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
 
     // Stop the clock, will set time to specific test values.
@@ -2075,7 +2075,7 @@
 
   @Test
   public void byMergedAfter() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
 
     // Stop the clock, will set time to specific test values.
@@ -2145,7 +2145,7 @@
 
   @Test
   public void updatedThenMergedOrder() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
 
     // Stop the clock, will set time to specific test values.
diff --git a/plugins/replication b/plugins/replication
index ced31c0..8f465cd 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit ced31c0c7d56cbb3e10a8da35a8ddd5db1bba550
+Subproject commit 8f465cd3f1f3039a937bd3d863c192e33d8347a2
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index ac8712b..510ce54 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -24,11 +24,13 @@
 
 Follow the instructions
 [here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
-to get and install Bazel.
+to get and install Bazel. The `npm install -g @bazel/bazelisk` method is
+probably easiest since you will have npm as part of Nodejs.
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-The minimum nodejs version supported is 10.x+.
+The minimum nodejs version supported is 10.x+. We recommend at least the latest
+LTS (v16 as of October 2022).
 
 ```sh
 # Debian experimental
@@ -80,11 +82,12 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the
-`polygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
-`.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
-to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
-this directory and select "Mark Directory As > Excluded" in the context menu.
+Modern IDEs should automatically handle typescript settings from the
+`polygerrit-ui/app/tsconfig.json` files. The `tsc` compiler places compiled
+files in the `.ts-out/pg` directory at the root of gerrit workspace and you can
+configure the IDE to exclude the whole .ts-out directory. To do it in the
+IntelliJ IDEA click on this directory and select "Mark Directory As > Excluded"
+in the context menu.
 
 However, if you receive some errors from IDE, you can try to configure IDE
 manually. For example, if IntelliJ IDEA shows
@@ -92,22 +95,27 @@
 options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
 
 
-## Serving files locally
+## Developing locally
 
-#### Web Dev Server
+The preferred method for development is to serve the web files locally using the
+Web Dev Server and then view a running gerrit instance (local or otherwise) to
+replace its web client with the local one using the Gerrit FE Dev Helper
+extension.
 
-To test the local frontend against production data or a local test site execute:
+### Web Dev Server
+
+The [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/) serves
+the compiled web files and dependencies unbundled over localhost. Start it using
+this command:
 
 ```sh
 yarn start
 ```
 
-This command starts the [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/).
 To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
 
-## Running locally against production data
 
 ### Chrome extension: Gerrit FE Dev Helper
 
@@ -120,7 +128,7 @@
 
 To use this extension, just follow its [readme here](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
 
-## Running locally against a Gerrit test site
+### Running locally against a Gerrit test site
 
 Set up a local test site once:
 
@@ -144,26 +152,38 @@
     --dev-cdn http://localhost:8081
 ```
 
+The Web Dev Server is currently not serving fonts or other static assets. Follow
+[Issue 16341](https://bugs.chromium.org/p/gerrit/issues/detail?id=16341) for
+fixing this issue.
+
 *NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
 
 ## Running Tests
 
 For daily development you typically only want to run and debug individual tests.
-There are several ways to run tests.
+Our tests run using the
+[Web Test Runner](https://modern-web.dev/docs/test-runner/overview/). There are
+several ways to trigger tests:
 
-* Run all tests:
+* Run all tests once:
 ```sh
 yarn test
 ```
 
-* Run all tests under bazel:
+* Run all tests and then watches for changes. Change a file will trigger all
+tests affected by the changes.
+```sh
+yarn test:watch
+```
+
+* Run all tests once under bazel:
 ```sh
 ./polygerrit-ui/app/run_test.sh
 ```
 
-* Run a single test file:
+* Run a single test file and rerun on any changes affecting it:
 ```
-yarn test:single "**/async-foreach-behavior_test.js"
+yarn test:single "**/gr-comment_test.ts"
 ```
 
 Compiling code:
@@ -172,34 +192,9 @@
 yarn compile:local
 
 # Watch mode:
-## Terminal 1:
 yarn compile:watch
-## Terminal 2, test & watch a file for example:
-yarn test:single "**/async-foreach-behavior_test.js"
 ```
 
-### Generated file overview
-
-A generated file starts with imports followed by a static content with
-different type definitions. You can skip this part - it doesn't contains
-anything usefule.
-
-After the static content there is a class definition. Example:
-```typescript
-export class GrCreateGroupDialogCheck extends GrCreateGroupDialog {
-  templateCheck() {
-    // Converted template
-    // Each HTML element from the template is wrapped into own block.
-  }
-}
-```
-
-The converted template usually quite straightforward, but in some cases
-additional functions are added. For example, `<element x=[[y.a]]>` converts into
-`el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
-then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union).
-
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
index 2b3c3df4..ad1f718 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -15,7 +15,7 @@
 import '@polymer/iron-dropdown/iron-dropdown';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {getAppContext} from '../../../services/app-context';
-import {notUndefined} from '../../../types/types';
+import {isDefined} from '../../../types/types';
 import {unique} from '../../../utils/common-util';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {when} from 'lit/directives/when.js';
@@ -215,7 +215,7 @@
   private renderExistingHashtags() {
     const hashtags = this.selectedChanges
       .flatMap(change => change.hashtags ?? [])
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     return html`
       <div class="chips">
@@ -302,7 +302,7 @@
     );
     this.existingHashtagSuggestions = (suggestions ?? [])
       .flatMap(change => change.hashtags ?? [])
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     return this.existingHashtagSuggestions.map(hashtag => {
       return {name: hashtag, value: hashtag};
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index ac1ba23..363b1ae 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -15,7 +15,7 @@
 import '@polymer/iron-dropdown/iron-dropdown';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {getAppContext} from '../../../services/app-context';
-import {notUndefined} from '../../../types/types';
+import {isDefined} from '../../../types/types';
 import {unique} from '../../../utils/common-util';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {when} from 'lit/directives/when.js';
@@ -190,7 +190,7 @@
   private renderExistingTopicsMode() {
     const topics = this.selectedChanges
       .map(change => change.topic)
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     const removeDisabled =
       this.selectedExistingTopics.size === 0 ||
@@ -347,7 +347,7 @@
     );
     this.existingTopicSuggestions = (suggestions ?? [])
       .map(change => change.topic)
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     return this.existingTopicSuggestions.map(topic => {
       return {name: topic, value: topic};
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 228e7ce..81245cc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -61,7 +61,7 @@
 import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
-  notUndefined,
+  isDefined,
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
@@ -1124,7 +1124,7 @@
       .then(response =>
         (response ?? [])
           .map(change => change.topic)
-          .filter(notUndefined)
+          .filter(isDefined)
           .filter(unique)
           .map(topic => {
             return {name: topic, value: topic};
@@ -1140,7 +1140,7 @@
       .then(response =>
         (response ?? [])
           .flatMap(change => change.hashtags ?? [])
-          .filter(notUndefined)
+          .filter(isDefined)
           .filter(unique)
           .map(hashtag => {
             return {name: hashtag, value: hashtag};
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 73653bb..ed1f46d 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
@@ -40,7 +40,7 @@
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {AccountInfo} from '../../../types/common';
-import {notUndefined} from '../../../types/types';
+import {isDefined} from '../../../types/types';
 import {Tab} from '../../../constants/constants';
 import {ChecksTabState, CommentTabState} from '../../../types/events';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
@@ -670,7 +670,7 @@
     return commentThreads
       .map(getFirstComment)
       .map(comment => comment?.author ?? this.selfAccount)
-      .filter(notUndefined);
+      .filter(isDefined);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index cd42a9f..26b9a63 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -33,7 +33,7 @@
 import {TargetElement} from '../../../api/plugin';
 import {
   FixIronA11yAnnouncer,
-  notUndefined,
+  isDefined,
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
@@ -129,7 +129,7 @@
   GrComment,
 } from '../../shared/gr-comment/gr-comment';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 
 export enum FocusTarget {
@@ -241,6 +241,8 @@
   @property({type: Object})
   projectConfig?: ConfigInfo;
 
+  @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment;
+
   @query('#reviewers') reviewersList?: GrAccountList;
 
   @query('#ccs') ccsList?: GrAccountList;
@@ -1411,13 +1413,13 @@
     )
       .filter(user => !this.currentAttentionSet.has(user))
       .map(user => allAccounts.find(a => getUserId(a) === user))
-      .filter(notUndefined);
+      .filter(isDefined);
 
     const newAttentionSetUsers = (
       await Promise.all(
         newAttentionSetAdditions.map(a => this.accountsModel.fillDetails(a))
       )
-    ).filter(notUndefined);
+    ).filter(isDefined);
 
     for (const user of newAttentionSetUsers) {
       let reason;
@@ -1446,11 +1448,7 @@
       reviewInput.remove_from_attention_set
     );
 
-    const patchsetLevelComment = queryAndAssert<GrComment>(
-      this,
-      '#patchsetLevelComment'
-    );
-    await patchsetLevelComment.save();
+    await this.patchsetLevelGrComment?.save();
 
     assertIsDefined(this.change, 'change');
     reviewInput.reviewers = this.computeReviewers();
@@ -1494,13 +1492,17 @@
     if (!section || section === FocusTarget.ANY) {
       section = this.chooseFocusTarget();
     }
-    if (section === FocusTarget.REVIEWERS) {
-      const reviewerEntry = this.reviewersList?.focusStart;
-      setTimeout(() => reviewerEntry?.focus());
-    } else if (section === FocusTarget.CCS) {
-      const ccEntry = this.ccsList?.focusStart;
-      setTimeout(() => ccEntry?.focus());
-    }
+    whenVisible(this, () => {
+      if (section === FocusTarget.REVIEWERS) {
+        const reviewerEntry = this.reviewersList?.focusStart;
+        reviewerEntry?.focus();
+      } else if (section === FocusTarget.CCS) {
+        const ccEntry = this.ccsList?.focusStart;
+        ccEntry?.focus();
+      } else {
+        this.patchsetLevelGrComment?.focus();
+      }
+    });
   }
 
   chooseFocusTarget() {
@@ -1860,11 +1862,7 @@
         bubbles: false,
       })
     );
-    const patchsetLevelComment = queryAndAssert<GrComment>(
-      this,
-      '#patchsetLevelComment'
-    );
-    await patchsetLevelComment.save();
+    await this.patchsetLevelGrComment?.save();
     this.rebuildReviewerArrays();
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index acd1755..8185139 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -15,6 +15,7 @@
   queryAndAssert,
   stubFlags,
   stubRestApi,
+  waitUntilVisible,
 } from '../../../test/test-utils';
 import {ChangeStatus, ReviewerState} from '../../../constants/constants';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -1444,14 +1445,10 @@
 
   test('focusOn', async () => {
     await element.updateComplete;
-    const clock = sinon.useFakeTimers();
     const chooseFocusTargetSpy = sinon.spy(element, 'chooseFocusTarget');
     element.focusOn();
-    // element.focus() is called after a setTimeout(). The focusOn() method
-    // does not trigger any changes in the element hence element.updateComplete
-    // resolves immediately and cannot be used here, hence tick the clock here
-    // explicitly instead
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 1);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1460,7 +1457,8 @@
     );
 
     element.focusOn(element.FocusTarget.ANY);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1469,7 +1467,8 @@
     );
 
     element.focusOn(element.FocusTarget.BODY);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1478,23 +1477,21 @@
     );
 
     element.focusOn(element.FocusTarget.REVIEWERS);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
-    assert.equal(
-      element?.shadowRoot?.activeElement?.tagName,
-      'GR-ACCOUNT-LIST'
+    await waitUntil(
+      () => element?.shadowRoot?.activeElement?.tagName === 'GR-ACCOUNT-LIST'
     );
     assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
 
     element.focusOn(element.FocusTarget.CCS);
-    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(
       element?.shadowRoot?.activeElement?.tagName,
       'GR-ACCOUNT-LIST'
     );
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
-    clock.restore();
+    await waitUntil(() => element?.shadowRoot?.activeElement?.id === 'ccs');
   });
 
   test('chooseFocusTarget', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 33fd2ee..2bda156 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -317,7 +317,15 @@
         if (this.view !== GerritView.CHANGE) return;
         const browserUrl = new URL(window.location.toString());
         const stateUrl = new URL(createChangeUrl(state), browserUrl);
+
+        // Keeping the hash and certain parameters are stop-gap solution. We
+        // should find better ways of maintaining an overall consistent URL
+        // state.
         stateUrl.hash = browserUrl.hash;
+        for (const p of browserUrl.searchParams.entries()) {
+          if (p[0] === 'experiment') stateUrl.searchParams.append(p[0], p[1]);
+        }
+
         if (browserUrl.toString() !== stateUrl.toString()) {
           page.replace(
             stateUrl.toString(),
@@ -1352,31 +1360,28 @@
   }
 
   handleQueryRoute(ctx: PageContext) {
-    const state: SearchViewState = {
+    const state: Partial<SearchViewState> = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
       offset: ctx.params[2],
-      changes: [],
-      loading: false,
     };
     // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.searchViewModel.setState(state);
+    this.setState(state as AppElementParams);
+    this.searchViewModel.updateState(state);
   }
 
   handleChangeIdQueryRoute(ctx: PageContext) {
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    const state: SearchViewState = {
+    const state: Partial<SearchViewState> = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
-      changes: [],
-      loading: false,
+      offset: undefined,
     };
     // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.searchViewModel.setState(state);
+    this.setState(state as AppElementParams);
+    this.searchViewModel.updateState(state);
   }
 
   handleQueryLegacySuffixRoute(ctx: PageContext) {
@@ -1537,8 +1542,9 @@
       patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
       edit: true,
-      tab: queryMap.get('tab') ?? '',
     };
+    const tab = queryMap.get('tab');
+    if (tab) state.tab = tab;
     if (queryMap.has('forceReload')) {
       state.forceReload = true;
       history.replaceState(
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index b550ea6..d1d0c86 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -403,9 +403,7 @@
         view: GerritView.SEARCH,
         query: 'project:foo/bar/baz',
         offset: undefined,
-        changes: [],
-        loading: false,
-      });
+      } as AppElementParams);
 
       ctx.params[1] = '123';
       ctx.params[2] = '123';
@@ -413,9 +411,7 @@
         view: GerritView.SEARCH,
         query: 'project:foo/bar/baz',
         offset: '123',
-        changes: [],
-        loading: false,
-      });
+      } as AppElementParams);
     });
 
     test('handleQueryLegacySuffixRoute', () => {
@@ -433,9 +429,8 @@
       assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
         view: GerritView.SEARCH,
         query: 'I0123456789abcdef0123456789abcdef01234567',
-        changes: [],
-        loading: false,
-      });
+        offset: undefined,
+      } as AppElementParams);
     });
 
     suite('handleRegisterRoute', () => {
@@ -1322,7 +1317,6 @@
           view: GerritView.CHANGE,
           patchNum: 3 as RevisionPatchSetNum,
           edit: true,
-          tab: '',
         };
 
         router.handleChangeEditRoute(ctx);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index c9603f4..99b47da 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -8,6 +8,7 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
+import '../../shared/gr-account-chip/gr-account-chip';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
@@ -209,6 +210,12 @@
           </iron-input>
         </span>
       </section>
+      <section>
+        <span class="title">Account chip preview</span>
+        <span class="value">
+          <gr-account-chip .account=${this.account}></gr-account-chip>
+        </span>
+      </section>
     </div>`;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index 518828a..bcd3964 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -109,6 +109,10 @@
               </iron-input>
             </span>
           </section>
+          <section>
+            <span class="title">Account chip preview</span>
+            <span class="value"><gr-account-chip></gr-account-chip></span>
+          </section>
         </div>
       `
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index e459c05..3fd1b82 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -7,12 +7,13 @@
 import '../../../styles/shared-styles';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {fireEvent} from '../../../utils/event-util';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {Key} from '../../../utils/dom-util';
 import {FitController} from '../../lit/fit-controller';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
 import {repeat} from 'lit/directives/repeat.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -64,8 +65,7 @@
 
   @query('#suggestions') suggestionsDiv?: HTMLDivElement;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly shortcuts = new ShortcutController(this);
 
   // visible for testing
   cursor = new GrCursorManager();
@@ -132,29 +132,19 @@
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
+    this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
+    this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+    this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
+    this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(addShortcut(this, {key: Key.UP}, () => this.handleUp()));
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, () => this.handleDown())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, () => this.handleEnter())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, () => this.handleEscape())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, () => this.handleTab())
-    );
   }
 
   override disconnectedCallback() {
     this.cursor.unsetCursor();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
     super.disconnectedCallback();
   }
 
@@ -165,8 +155,13 @@
   }
 
   override updated(changedProperties: PropertyValues) {
-    if (changedProperties.has('suggestions')) {
-      this.onSuggestionsChanged();
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('isHidden')
+    ) {
+      if (!this.isHidden) {
+        this.computeCursorStopsAndRefit();
+      }
     }
   }
 
@@ -209,7 +204,6 @@
 
   open() {
     this.isHidden = false;
-    this.onSuggestionsChanged();
   }
 
   getCurrentText() {
@@ -299,14 +293,12 @@
     return this.cursor.target;
   }
 
-  onSuggestionsChanged() {
+  computeCursorStopsAndRefit() {
     if (this.suggestions.length > 0) {
-      if (!this.isHidden) {
-        this.cursor.stops = Array.from(
-          this.suggestionsDiv?.querySelectorAll('li') ?? []
-        );
-        this.resetCursorIndex();
-      }
+      this.cursor.stops = Array.from(
+        this.suggestionsDiv?.querySelectorAll('li') ?? []
+      );
+      this.resetCursorIndex();
     } else {
       this.cursor.stops = [];
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 9a0ed5e..641dd2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -170,7 +170,7 @@
   });
 
   test('updated suggestions resets cursor stops', async () => {
-    const resetStopsSpy = sinon.spy(element, 'onSuggestionsChanged');
+    const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
     element.suggestions = [];
     await waitUntil(() => resetStopsSpy.called);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 721349f..056dfe5 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -49,7 +49,7 @@
 } from '../../../types/events';
 import {fire, fireEvent} from '../../../utils/event-util';
 import {assertIsDefined, assert} from '../../../utils/common-util';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
@@ -811,7 +811,8 @@
     // fixed. Currently diff line doesn't match commit message line, because
     // of metadata in diff, which aren't in content api request.
     if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
-    if (this.isOwner) return nothing;
+    // TODO(milutin): disable user suggestions for owners, after user study.
+    // if (this.isOwner) return nothing;
     return html`<gr-button
       link
       class="action suggestEdit"
@@ -974,6 +975,14 @@
     }
   }
 
+  override updated(changed: PropertyValues) {
+    if (changed.has('editing')) {
+      if (this.editing && !this.permanentEditingMode) {
+        whenVisible(this, () => this.textarea?.putCursorAtEnd());
+      }
+    }
+  }
+
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
     if (changed.has('editing')) {
@@ -1062,7 +1071,6 @@
       this.unresolved = this.comment?.unresolved ?? true;
       this.originalMessage = this.messageText;
       this.originalUnresolved = this.unresolved;
-      setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
     }
 
     // Parent components such as the reply dialog might be interested in whether
@@ -1081,6 +1089,10 @@
     return !this.messageText?.trimEnd();
   }
 
+  override focus() {
+    this.textarea?.focus();
+  }
+
   private handleEsc() {
     // vim users don't like ESC to cancel/discard, so only do this when the
     // comment text is empty.
@@ -1178,6 +1190,9 @@
 
   async save() {
     if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+    // If it's an unsaved comment then it does not have a draftID yet which
+    // means sending another save() request will create a new draft
+    if (isUnsaved(this.comment) && this.saving) return;
 
     try {
       this.saving = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 9ced917..76b3c15 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from '../../../api/plugin';
-import {notUndefined} from '../../../types/types';
+import {isDefined} from '../../../types/types';
 import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -161,7 +161,7 @@
       return [];
     }
     return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
-      notUndefined
+      isDefined
     );
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 9a114e6..b38d71b 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -302,14 +302,16 @@
     return this.textarea!.textarea;
   }
 
+  override focus() {
+    this.textarea?.textarea.focus();
+  }
+
   putCursorAtEnd() {
     const textarea = this.getNativeTextarea();
     // Put the cursor at the end always.
     textarea.selectionStart = textarea.value.length;
     textarea.selectionEnd = textarea.selectionStart;
-    setTimeout(() => {
-      textarea.focus();
-    });
+    textarea.focus();
   }
 
   private getVisibleDropdown() {
@@ -343,7 +345,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.getVisibleDropdown().cursorUp();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private handleDownKey(e: KeyboardEvent) {
@@ -353,7 +355,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.getVisibleDropdown().cursorDown();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private handleTabKey(e: KeyboardEvent) {
@@ -568,7 +570,7 @@
   async handleTextChanged() {
     await this.computeSuggestions();
     this.openOrResetDropdown();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private openEmojiDropdown() {
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 6b35056..965d6b1 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -753,15 +753,14 @@
         patchset === ChecksPatchset.LATEST
           ? this.changeModel.latestPatchNum$
           : this.checksSelectedPatchsetNumber$,
-        this.reloadSubjects[pluginName].pipe(
-          throttleTime(1000, undefined, {trailing: true, leading: true})
-        ),
+        this.reloadSubjects[pluginName],
         pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
         this.documentVisibilityChange$,
       ])
         .pipe(
           takeWhile(_ => !!this.providers[pluginName]),
           filter(_ => document.visibilityState !== 'hidden'),
+          throttleTime(500, undefined, {leading: true, trailing: true}),
           switchMap(([change, patchNum]): Observable<FetchResponse> => {
             if (!change || !patchNum) return of(this.empty());
             if (typeof patchNum !== 'number') return of(this.empty());
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 3489c5a..a19db6b 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -108,7 +108,7 @@
     assert.equal(model.changeNum, testChange._number);
   });
 
-  test('reload throttle', async () => {
+  test('fetch throttle', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
     model.changeModel.change$.subscribe(c => (change = c));
@@ -125,16 +125,31 @@
     const testChange = createParsedChange();
     model.changeModel.updateStateChange(testChange);
     await waitUntil(() => change === testChange);
-    clock.tick(1);
+
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+
+    // Does not emit at 'leading' of throttle interval,
+    // because fetch() is not called when change is undefined.
+    assert.equal(fetchSpy.callCount, 0);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
     assert.equal(fetchSpy.callCount, 1);
 
-    // The second reload call will be processed, but only after a 1s throttle.
     model.reload('test-plugin');
-    clock.tick(100);
-    assert.equal(fetchSpy.callCount, 1);
-    // 2000 ms is greater than the 1000 ms throttle time.
-    clock.tick(2000);
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    // emits at 'leading' of throttle interval
     assert.equal(fetchSpy.callCount, 2);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
+    assert.equal(fetchSpy.callCount, 3);
   });
 
   test('triggerAction', async () => {
@@ -282,8 +297,8 @@
     const testChange = createParsedChange();
     model.changeModel.updateStateChange(testChange);
     await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
-    clock.tick(1);
     const pollCount = fetchSpy.callCount;
 
     // polling should continue while we wait
@@ -309,6 +324,7 @@
     const testChange = createParsedChange();
     model.changeModel.updateStateChange(testChange);
     await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     clock.tick(1);
     const pollCount = fetchSpy.callCount;
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 7ccdf91..6a5933c 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -17,7 +17,7 @@
 import {PatchSetNumber} from '../../api/rest-api';
 import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
 import {PROVIDED_FIX_ID} from '../../utils/comment-util';
 import {assert, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
@@ -94,7 +94,7 @@
   if (!result?.fixes) return;
   const fixSuggestions = result.fixes
     .map(f => rectifyFix(f, result?.checkName))
-    .filter(notUndefined);
+    .filter(isDefined);
   if (fixSuggestions.length === 0) return;
   const eventDetail: OpenFixPreviewEventDetail = {
     patchNum: result.patchset as PatchSetNumber,
@@ -116,7 +116,7 @@
   if (!fix?.replacements) return undefined;
   const replacements = fix.replacements
     .map(rectifyReplacement)
-    .filter(notUndefined);
+    .filter(isDefined);
   if (replacements.length === 0) return undefined;
 
   return {
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 8ed4b69..da49ddb 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -30,7 +30,7 @@
 import {RouterModel} from '../../services/router/router-model';
 import {Finalizable} from '../../services/registry';
 import {define} from '../dependency';
-import {combineLatest, forkJoin, from, Observable} from 'rxjs';
+import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
 import {fire, fireAlert, fireEvent} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -52,7 +52,7 @@
   shareReplay,
   switchMap,
 } from 'rxjs/operators';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
 
 export interface CommentState {
   /** undefined means 'still loading' */
@@ -291,11 +291,15 @@
           (user, index) =>
             index === users.findIndex(u => getUserId(u) === getUserId(user))
         );
+        // forkJoin only emits value when the array is non-empty
+        if (uniqueUsers.length === 0) {
+          return of(uniqueUsers);
+        }
         const filledUsers$: Observable<AccountInfo | undefined>[] =
           uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
         return forkJoin(filledUsers$);
       }),
-      map(users => users.filter(notUndefined)),
+      map(users => users.filter(isDefined)),
       distinctUntilChanged(deepEqual),
       shareReplay(1)
     );
@@ -314,11 +318,15 @@
           (user, index) =>
             index === users.findIndex(u => getUserId(u) === getUserId(user))
         );
+        // forkJoin only emits value when the array is non-empty
+        if (uniqueUsers.length === 0) {
+          return of(uniqueUsers);
+        }
         const filledUsers$: Observable<AccountInfo | undefined>[] =
           uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
         return forkJoin(filledUsers$);
       }),
-      map(users => users.filter(notUndefined)),
+      map(users => users.filter(isDefined)),
       distinctUntilChanged(deepEqual),
       shareReplay(1)
     );
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 6cbdc63..32ea1bc 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -150,4 +150,40 @@
 
     assert.deepEqual(mentionedUsers, [account]);
   });
+
+  test('empty mentions are emitted', async () => {
+    const account = {
+      ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    };
+    stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+    const model = new CommentsModel(
+      getAppContext().routerModel,
+      testResolver(changeModelToken),
+      getAppContext().accountsModel,
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    let mentionedUsers: AccountInfo[] = [];
+    const draft = {...createDraft(), message: 'hey @abc@def.com'};
+    model.mentionedUsersInDrafts$.subscribe(x => (mentionedUsers = x));
+    model.setState({
+      drafts: {
+        'abc.txt': [draft],
+      },
+      discardedDrafts: [],
+    });
+
+    await waitUntil(() => mentionedUsers.length > 0);
+
+    assert.deepEqual(mentionedUsers, [account]);
+
+    model.setState({
+      drafts: {
+        'abc.txt': [],
+      },
+      discardedDrafts: [],
+    });
+    await waitUntil(() => mentionedUsers.length === 0);
+  });
 });
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 2f2fe7d..428ddbb 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -26,7 +26,7 @@
 import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
 import {Model} from '../model';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
 
 export interface UserState {
   /**
@@ -99,17 +99,17 @@
   readonly preferences$: Observable<PreferencesInfo> = select(
     this.state$,
     userState => userState.preferences
-  ).pipe(filter(notUndefined));
+  ).pipe(filter(isDefined));
 
   readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
     this.state$,
     userState => userState.diffPreferences
-  ).pipe(filter(notUndefined));
+  ).pipe(filter(isDefined));
 
   readonly editPreferences$: Observable<EditPreferencesInfo> = select(
     this.state$,
     userState => userState.editPreferences
-  ).pipe(filter(notUndefined));
+  ).pipe(filter(isDefined));
 
   readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
     this.preferences$,
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 0bb02d8..e90c9be 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -20,7 +20,7 @@
 import {assertNever} from '../../utils/common-util';
 import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
 import {allSettled, isFulfilled} from '../../utils/async-util';
-import {notUndefined, ParsedChangeInfo} from '../../types/types';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
 import {accountKey} from '../../utils/account-util';
 import {
   AccountId,
@@ -63,7 +63,7 @@
     const suggestionsByChangeIndex = resultsByChangeIndex
       .filter(isFulfilled)
       .map(result => result.value)
-      .filter(notUndefined);
+      .filter(isDefined);
     if (suggestionsByChangeIndex.length !== resultsByChangeIndex.length) {
       // one of the requests failed, so don't allow any suggestions.
       return [];
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index d6ad434..6d45ad4 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -15,7 +15,7 @@
 import {UserModel} from '../models/user/user-model';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
-import {Key, Modifier} from '../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../utils/dom-util';
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
 import {HighlightService} from '../services/highlight/highlight-service';
@@ -220,6 +220,12 @@
   });
 }
 
+export async function waitUntilVisible(element: Element): Promise<void> {
+  return new Promise(resolve => {
+    whenVisible(element, () => resolve());
+  });
+}
+
 export function waitUntilCalled(stub: SinonStub | SinonSpy, name: string) {
   return waitUntil(() => stub.called, `${name} was not called`);
 }
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 04052e2..8d55e36 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -20,7 +20,7 @@
 } from './common';
 import {AuthRequestInit} from '../services/gr-auth/gr-auth';
 
-export function notUndefined<T>(x: T): x is NonNullable<T> {
+export function isDefined<T>(x: T): x is NonNullable<T> {
   return x !== undefined && x !== null;
 }
 
diff --git a/tools/BUILD b/tools/BUILD
index 68f013b..e25dcc5 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -126,7 +126,7 @@
         "-Xep:DoNotCallSuggester:ERROR",
         "-Xep:DoNotClaimAnnotations:ERROR",
         "-Xep:DoNotMock:ERROR",
-        "-Xep:DoNotMockAutoValue:WARN",
+        "-Xep:DoNotMockAutoValue:ERROR",
         "-Xep:DoubleBraceInitialization:ERROR",
         "-Xep:DoubleCheckedLocking:ERROR",
         "-Xep:DuplicateMapKeys:ERROR",
@@ -147,7 +147,7 @@
         "-Xep:EqualsUsingHashCode:ERROR",
         "-Xep:EqualsWrongThing:ERROR",
         "-Xep:ErroneousThreadPoolConstructorChecker:ERROR",
-        "-Xep:EscapedEntity:WARN",
+        "-Xep:EscapedEntity:ERROR",
         "-Xep:ExpectedExceptionChecker:ERROR",
         "-Xep:ExtendingJUnitAssert:ERROR",
         "-Xep:ExtendsAutoValue:ERROR",
@@ -242,7 +242,7 @@
         "-Xep:JavaLocalTimeGetNano:ERROR",
         "-Xep:JavaPeriodGetDays:ERROR",
         "-Xep:JavaTimeDefaultTimeZone:ERROR",
-        "-Xep:JavaUtilDate:WARN",
+        "-Xep:JavaUtilDate:ERROR",
         "-Xep:JdkObsolete:ERROR",
         "-Xep:JodaConstructors:ERROR",
         "-Xep:JodaDateTimeConstants:ERROR",