Merge "Update javadoc for ProjectState"
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 29bb409..e34071f 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -19,8 +19,7 @@
 
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
-    9 or newer yet.
+. Java SE Runtime Environment version 11 and up.
 
 == Download Gerrit
 
diff --git a/WORKSPACE b/WORKSPACE
index 0caf8c3..428a6a4 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -991,5 +991,5 @@
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+    sha1 = "595e3a50f59cd3c1d281ca6c1bc4037e277a1353",
 )
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index a613588..d46fb78 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -282,6 +282,11 @@
     return this;
   }
 
+  public PushOneCommit noParent() throws Exception {
+    commitBuilder.noParents();
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 36f7b53..df03fd5 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -29,23 +29,23 @@
   /**
    * Expression of the condition that makes the requirement applicable. The expression should be
    * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
-   * irrelevant for the change (i.e. {@link #blockingExpression()} and {@link #overrideExpression()}
-   * become irrelevant).
+   * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
+   * #overrideExpression()} become irrelevant).
    *
    * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
    */
   public abstract Optional<SubmitRequirementExpression> applicabilityExpression();
 
   /**
-   * Expression of the condition that blocks the submission of a change. The expression should be
-   * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
+   * Expression of the condition that allows the submission of a change. The expression should be
+   * evaluated for a specific {@link Change} and if it returns true, the requirement becomes
    * fulfilled for the change.
    */
-  public abstract SubmitRequirementExpression blockingExpression();
+  public abstract SubmitRequirementExpression submittabilityExpression();
 
   /**
    * Expression that, if evaluated to true, causes the submit requirement to be fulfilled,
-   * regardless of the blocking expression. This expression should be evaluated for a specific
+   * regardless of the submittability expression. This expression should be evaluated for a specific
    * {@link Change}.
    *
    * <p>An empty {@link Optional} indicates that the requirement is not overridable.
@@ -72,7 +72,8 @@
     public abstract Builder setApplicabilityExpression(
         Optional<SubmitRequirementExpression> applicabilityExpression);
 
-    public abstract Builder setBlockingExpression(SubmitRequirementExpression blockingExpression);
+    public abstract Builder setSubmittabilityExpression(
+        SubmitRequirementExpression submittabilityExpression);
 
     public abstract Builder setOverrideExpression(
         Optional<SubmitRequirementExpression> overrideExpression);
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index 7b31304..c978347 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -19,12 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
 
-/**
- * Describe a applicability, blocking or override expression of a {@link SubmitRequirement}.
- *
- * <p>TODO: Store the tree representation of the parsed expression internally and throw an exception
- * upon creation if the expression syntax is invalid.
- */
+/** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
 @AutoValue
 public abstract class SubmitRequirementExpression {
 
@@ -45,5 +40,5 @@
   }
 
   /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
-  public abstract String expression();
+  public abstract String expressionString();
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
new file mode 100644
index 0000000..94c0e91
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Optional;
+
+/** Result of evaluating a submit requirement expression on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementExpressionResult {
+
+  /**
+   * Entity detailing the result of evaluating a Submit requirement expression. Contains an empty
+   * {@link Optional} if {@link #status()} is equal to {@link Status#ERROR}.
+   */
+  public abstract Optional<PredicateResult> predicateResult();
+
+  public abstract Optional<String> errorMessage();
+
+  public Status status() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().status() ? Status.PASS : Status.FAIL;
+    }
+    return Status.ERROR;
+  }
+
+  public static SubmitRequirementExpressionResult create(PredicateResult predicateResult) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        Optional.of(predicateResult), Optional.empty());
+  }
+
+  public static SubmitRequirementExpressionResult error(String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        Optional.empty(), Optional.of(errorMessage));
+  }
+
+  /**
+   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. If
+   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   */
+  public ImmutableList<PredicateResult> getPassingAtoms() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().getAtoms(/* status= */ true);
+    }
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. If
+   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   */
+  public ImmutableList<PredicateResult> getFailingAtoms() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().getAtoms(/* status= */ false);
+    }
+    return ImmutableList.of();
+  }
+
+  public enum Status {
+    /** Submit requirement expression is fulfilled for a given change. */
+    PASS,
+
+    /** Submit requirement expression is failing for a given change. */
+    FAIL,
+
+    /** Submit requirement expression contains invalid syntax and is not parsable. */
+    ERROR
+  }
+
+  /**
+   * Entity detailing the result of evaluating a predicate.
+   *
+   * <p>Example - branch:refs/heads/foo and has:unresolved
+   *
+   * <p>The above predicate is an "And" predicate having two child predicates:
+   *
+   * <ul>
+   *   <li>branch:refs/heads/foo
+   *   <li>has:unresolved
+   * </ul>
+   *
+   * <p>Each child predicate as well as the parent contains the result of its evaluation.
+   */
+  @AutoValue
+  public abstract static class PredicateResult {
+    abstract ImmutableList<PredicateResult> childPredicateResults();
+
+    abstract String predicateString();
+
+    /** true if the predicate is passing for a given change. */
+    abstract boolean status();
+
+    /**
+     * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
+     * {@code status} parameter.
+     */
+    ImmutableList<PredicateResult> getAtoms(boolean status) {
+      ImmutableList.Builder<PredicateResult> atomsList = ImmutableList.builder();
+      getAtomsRecursively(atomsList, status);
+      return atomsList.build();
+    }
+
+    private void getAtomsRecursively(ImmutableList.Builder<PredicateResult> list, boolean status) {
+      if (childPredicateResults().isEmpty() && status() == status) {
+        list.add(this);
+        return;
+      }
+      childPredicateResults().forEach(c -> c.getAtomsRecursively(list, status));
+    }
+
+    public static Builder builder() {
+      return new AutoValue_SubmitRequirementExpressionResult_PredicateResult.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
+
+      protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
+
+      public abstract Builder predicateString(String value);
+
+      public abstract Builder status(boolean value);
+
+      public Builder addChildPredicateResult(PredicateResult result) {
+        childPredicateResultsBuilder().add(result);
+        return this;
+      }
+
+      public abstract PredicateResult build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
new file mode 100644
index 0000000..7b4d609
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import java.util.Optional;
+
+/** Result of evaluating a {@link SubmitRequirement} on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementResult {
+  /** Result of evaluating a {@link SubmitRequirement#applicabilityExpression()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
+
+  /**
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   */
+  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+
+  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
+
+  @Memoized
+  public Status status() {
+    if (assertError(submittabilityExpressionResult())
+        || assertError(applicabilityExpressionResult())
+        || assertError(overrideExpressionResult())) {
+      return Status.ERROR;
+    } else if (assertFail(applicabilityExpressionResult())) {
+      return Status.NOT_APPLICABLE;
+    } else if (assertPass(overrideExpressionResult())) {
+      return Status.OVERRIDDEN;
+    } else if (assertPass(submittabilityExpressionResult())) {
+      return Status.SATISFIED;
+    } else {
+      return Status.UNSATISFIED;
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirementResult.Builder();
+  }
+
+  public enum Status {
+    /** Submit requirement is fulfilled. */
+    SATISFIED,
+
+    /**
+     * Submit requirement is not satisfied. Happens when {@link
+     * SubmitRequirement#submittabilityExpression()} evaluates to false.
+     */
+    UNSATISFIED,
+
+    /**
+     * Submit requirement is overridden. Happens when {@link SubmitRequirement#overrideExpression()}
+     * evaluates to true.
+     */
+    OVERRIDDEN,
+
+    /**
+     * Submit requirement is not applicable for a given change. Happens when {@link
+     * SubmitRequirement#applicabilityExpression()} evaluates to false.
+     */
+    NOT_APPLICABLE,
+
+    /**
+     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
+     * parsable.
+     */
+    ERROR
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder applicabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+
+    public abstract Builder overrideExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract SubmitRequirementResult build();
+  }
+
+  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertStatus(
+      SubmitRequirementExpressionResult expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.status() == status;
+  }
+
+  private boolean assertStatus(
+      Optional<SubmitRequirementExpressionResult> expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
index ad015d1..47a377f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
@@ -28,7 +28,8 @@
         .setDescription(Optional.ofNullable(Strings.emptyToNull(proto.getDescription())))
         .setApplicabilityExpression(
             SubmitRequirementExpression.of(proto.getApplicabilityExpression()))
-        .setBlockingExpression(SubmitRequirementExpression.create(proto.getBlockingExpression()))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(proto.getSubmittabilityExpression()))
         .setOverrideExpression(SubmitRequirementExpression.of(proto.getOverrideExpression()))
         .setAllowOverrideInChildProjects(proto.getAllowOverrideInChildProjects())
         .build();
@@ -40,10 +41,11 @@
         .setName(submitRequirement.name())
         .setDescription(submitRequirement.description().orElse(""))
         .setApplicabilityExpression(
-            submitRequirement.applicabilityExpression().orElse(emptyExpression).expression())
-        .setBlockingExpression(submitRequirement.blockingExpression().expression())
+            submitRequirement.applicabilityExpression().orElse(emptyExpression).expressionString())
+        .setSubmittabilityExpression(
+            submitRequirement.submittabilityExpression().expressionString())
         .setOverrideExpression(
-            submitRequirement.overrideExpression().orElse(emptyExpression).expression())
+            submitRequirement.overrideExpression().orElse(emptyExpression).expressionString())
         .setAllowOverrideInChildProjects(submitRequirement.allowOverrideInChildProjects())
         .build();
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 71cb8c9..76573f6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
 import java.io.IOException;
@@ -127,4 +130,14 @@
     }
     return footerLines.get(key.getName().toLowerCase());
   }
+
+  public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
+    return !hasChangeMessage
+        && footerLines
+            .keySet()
+            .equals(
+                Sets.newHashSet(
+                    FOOTER_PATCH_SET.getName().toLowerCase(),
+                    FOOTER_ATTENTION.getName().toLowerCase()));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index f4d6cd3..f12176b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -327,7 +327,6 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    updateCount++;
     Timestamp commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
@@ -370,7 +369,8 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
+    boolean hasChangeMessage =
+        parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     if (topic == null) {
       topic = parseTopic(commit);
     }
@@ -435,6 +435,9 @@
 
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
+    if (countTowardsMaxUpdatesLimit(commit, hasChangeMessage)) {
+      updateCount++;
+    }
   }
 
   private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
@@ -720,7 +723,7 @@
     }
   }
 
-  private void parseChangeMessage(
+  private boolean parseChangeMessage(
       PatchSet.Id psId,
       Account.Id accountId,
       Account.Id realAccountId,
@@ -728,7 +731,7 @@
       Timestamp ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
-      return;
+      return false;
     }
 
     ChangeMessage changeMessage =
@@ -740,7 +743,7 @@
             changeMsgString.get(),
             realAccountId,
             tag);
-    allChangeMessages.add(changeMessage);
+    return allChangeMessages.add(changeMessage);
   }
 
   public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
@@ -1197,4 +1200,9 @@
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
+
+  protected boolean countTowardsMaxUpdatesLimit(
+      ChangeNotesCommit commit, boolean hasChangeMessage) {
+    return !commit.isAttentionSetCommitOnly(hasChangeMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index eca2658..e450779 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -30,7 +30,7 @@
 
   /**
    * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
-   * not the auto-merge.
+   * not the auto-merge. If set to 0, then comparison is for a root commit.
    */
   abstract Optional<Integer> parentNum();
 
@@ -48,6 +48,10 @@
     return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
+  public static ComparisonType againstRoot() {
+    return new AutoValue_ComparisonType(Optional.of(0), false);
+  }
+
   private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
     return new AutoValue_ComparisonType(parent, automerge);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 6217239..dbbb7a6 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -341,6 +342,10 @@
 
     abstract ObjectId newCommit();
 
+    /**
+     * Base commit represents the old commit of the diff. For diffs against the root commit, this
+     * should be set to {@code EMPTY_TREE_ID}.
+     */
     abstract ObjectId baseCommit();
 
     abstract ComparisonType comparisonType();
@@ -386,6 +391,11 @@
       return result.build();
     }
     int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 0) {
+      result.baseCommit(EMPTY_TREE_ID);
+      result.comparisonType(ComparisonType.againstRoot());
+      return result.build();
+    }
     if (numParents == 1) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(1));
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 395312f..2133474 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -93,7 +94,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(4)
+            .version(5)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -189,6 +190,9 @@
     private ComparisonType getComparisonType(
         RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
         throws IOException {
+      if (oldCommitId.equals(EMPTY_TREE_ID)) {
+        return ComparisonType.againstRoot();
+      }
       RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
       RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
       for (int i = 0; i < newCommit.getParentCount(); i++) {
@@ -211,7 +215,7 @@
     }
 
     /**
-     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" or "Merge list" magic paths.
      */
     private FileDiffOutput createMagicPathEntry(
         FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
@@ -219,7 +223,10 @@
         RawTextComparator cmp = comparatorFor(key.whitespace());
         ComparisonType comparisonType =
             getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
-        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit aCommit =
+            key.oldCommit().equals(EMPTY_TREE_ID)
+                ? null
+                : DiffUtil.getRevCommit(rw, key.oldCommit());
         RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
         return magicPath == MagicPath.COMMIT
             ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
@@ -248,16 +255,19 @@
       }
     }
 
+    /**
+     * Creates a commit entry. {@code oldCommit} is null if the comparison is against a root commit.
+     */
     private FileDiffOutput createCommitEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forCommit(reader, oldCommit);
       Text bText = Text.forCommit(reader, newCommit);
@@ -272,16 +282,20 @@
           diffAlgorithm);
     }
 
+    /**
+     * Creates a merge list entry. {@code oldCommit} is null if the comparison is against a root
+     * commit.
+     */
     private FileDiffOutput createMergeListEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forMergeList(comparisonType, reader, oldCommit);
       Text bText = Text.forMergeList(comparisonType, reader, newCommit);
@@ -297,7 +311,7 @@
     }
 
     private static FileDiffOutput createMagicFileDiffOutput(
-        ObjectId oldCommit,
+        @Nullable ObjectId oldCommit,
         ObjectId newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
@@ -317,7 +331,7 @@
       FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
       Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
       return FileDiffOutput.builder()
-          .oldCommitId(oldCommit)
+          .oldCommitId(oldCommit == null ? EMPTY_TREE_ID : oldCommit)
           .newCommitId(newCommit)
           .comparisonType(comparisonType)
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
index a478fcf..7ac9343 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Cache key for the {@link FileDiffCache}. */
@@ -35,7 +36,10 @@
   /** A specific git project / repository. */
   public abstract Project.NameKey project();
 
-  /** The 20 bytes SHA-1 commit ID of the old commit used in the diff. */
+  /**
+   * The 20 bytes SHA-1 commit ID of the old commit used in the diff. If set to {@link
+   * Constants#EMPTY_TREE_ID}, the diff is performed against the root commit.
+   */
   public abstract ObjectId oldCommit();
 
   /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 027cf17..9f898d9 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -130,7 +130,7 @@
   public static final String KEY_SR_NAME = "name";
   public static final String KEY_SR_DESCRIPTION = "description";
   public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicabilityExpression";
-  public static final String KEY_SR_BLOCKING_EXPRESSION = "blockingExpression";
+  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittabilityExpression";
   public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideExpression";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
 
@@ -985,7 +985,7 @@
       lowerNames.put(lower, name);
       String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
       String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_BLOCKING_EXPRESSION);
+      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
       boolean canInherit =
           rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
@@ -995,7 +995,7 @@
             ValidationError.create(
                 PROJECT_CONFIG,
                 (String.format(
-                    "Submit requirement \"%s\" does not define a blocking expression."
+                    "Submit requirement \"%s\" does not define a submittability expression."
                         + " Skipping this requirement.",
                     name))));
         continue;
@@ -1009,7 +1009,7 @@
               .setName(name)
               .setDescription(Optional.ofNullable(description))
               .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
-              .setBlockingExpression(SubmitRequirementExpression.create(blockExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
               .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
               .setAllowOverrideInChildProjects(canInherit)
               .build();
@@ -1701,19 +1701,19 @@
               SUBMIT_REQUIREMENT,
               name,
               KEY_SR_APPLICABILITY_EXPRESSION,
-              sr.applicabilityExpression().get().expression());
+              sr.applicabilityExpression().get().expressionString());
         }
         rc.setString(
             SUBMIT_REQUIREMENT,
             name,
-            KEY_SR_BLOCKING_EXPRESSION,
-            sr.blockingExpression().expression());
+            KEY_SR_SUBMITTABILITY_EXPRESSION,
+            sr.submittabilityExpression().expressionString());
         if (sr.overrideExpression().isPresent()) {
           rc.setString(
               SUBMIT_REQUIREMENT,
               name,
               KEY_SR_OVERRIDE_EXPRESSION,
-              sr.overrideExpression().get().expression());
+              sr.overrideExpression().get().expressionString());
         }
         rc.setBoolean(
             SUBMIT_REQUIREMENT,
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
new file mode 100644
index 0000000..1d154dd
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2021 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/** Evaluates submit requirements for different change data. */
+@Singleton
+public class SubmitRequirementsEvaluator {
+  private final Provider<ChangeQueryBuilder> changeQueryBuilderProvider;
+
+  @Inject
+  private SubmitRequirementsEvaluator(Provider<ChangeQueryBuilder> changeQueryBuilderProvider) {
+    this.changeQueryBuilderProvider = changeQueryBuilderProvider;
+  }
+
+  /**
+   * Validate a {@link SubmitRequirementExpression}. Callers who wish to validate submit
+   * requirements upon creation or update should use this method.
+   *
+   * @param expression entity containing the expression string.
+   * @throws QueryParseException the expression string contains invalid syntax and can't be parsed.
+   */
+  public void validateExpression(SubmitRequirementExpression expression)
+      throws QueryParseException {
+    changeQueryBuilderProvider.get().parse(expression.expressionString());
+  }
+
+  /** Evaluate a {@link SubmitRequirement} on a given {@link ChangeData}. */
+  public SubmitRequirementResult evaluate(SubmitRequirement sr, ChangeData cd) {
+    SubmitRequirementExpressionResult blockingResult =
+        evaluateExpression(sr.submittabilityExpression(), cd);
+
+    Optional<SubmitRequirementExpressionResult> applicabilityResult =
+        sr.applicabilityExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+            : Optional.empty();
+
+    Optional<SubmitRequirementExpressionResult> overrideResult =
+        sr.overrideExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+            : Optional.empty();
+
+    return SubmitRequirementResult.builder()
+        .submittabilityExpressionResult(blockingResult)
+        .applicabilityExpressionResult(applicabilityResult)
+        .overrideExpressionResult(overrideResult)
+        .build();
+  }
+
+  /** Evaluate a {@link SubmitRequirementExpression} using change data. */
+  public SubmitRequirementExpressionResult evaluateExpression(
+      SubmitRequirementExpression expression, ChangeData changeData) {
+    try {
+      Predicate<ChangeData> predicate =
+          changeQueryBuilderProvider.get().parse(expression.expressionString());
+      PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
+      return SubmitRequirementExpressionResult.create(predicateResult);
+    } catch (QueryParseException e) {
+      return SubmitRequirementExpressionResult.error(e.getMessage());
+    }
+  }
+
+  /** Evaluate the predicate recursively using change data. */
+  private PredicateResult evaluatePredicateTree(
+      Predicate<ChangeData> predicate, ChangeData changeData) {
+    PredicateResult.Builder predicateResult =
+        PredicateResult.builder()
+            .predicateString(predicate.toString())
+            .status(predicate.asMatchable().match(changeData));
+    predicate
+        .getChildren()
+        .forEach(
+            c -> predicateResult.addChildPredicateResult(evaluatePredicateTree(c, changeData)));
+    return predicateResult.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 9bdc420..1a55b82 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
@@ -25,6 +26,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -35,7 +37,10 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -46,6 +51,8 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
@@ -57,6 +64,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
 import org.eclipse.jgit.lib.ObjectId;
@@ -81,11 +89,14 @@
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private DiffOperations diffOperations;
+  @Inject private ProjectOperations projectOperations;
 
   private boolean intraline;
   private boolean useNewDiffCacheListFiles;
   private boolean useNewDiffCacheGetDiff;
 
+  private ObjectId initialCommit;
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
@@ -104,6 +115,8 @@
         baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    initialCommit = headCommit;
+
     commit1 =
         addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
 
@@ -124,10 +137,33 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
-  @Ignore
   @Test
   public void diffWithRootCommit() throws Exception {
-    // TODO(ghareeb): Implement this test
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
+
+    testRepo.reset(initialCommit);
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of("f.txt", "content"))
+            .noParent();
+    push.setForce(true);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+
+    Map<String, FileDiffOutput> modifiedFiles =
+        diffOperations.listModifiedFilesAgainstParent(project, result.getCommit(), null);
+
+    assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
+    assertThat(
+            modifiedFiles.values().stream()
+                .map(FileDiffOutput::oldCommitId)
+                .collect(Collectors.toSet()))
+        .containsExactly(EMPTY_TREE_ID);
+    assertThat(modifiedFiles.get("/COMMIT_MSG").changeType()).isEqualTo(Patch.ChangeType.ADDED);
+    assertThat(modifiedFiles.get("f.txt").changeType()).isEqualTo(Patch.ChangeType.ADDED);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
new file mode 100644
index 0000000..23047a4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -0,0 +1,265 @@
+// Copyright (C) 2021 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.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
+  @Inject SubmitRequirementsEvaluator evaluator;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+
+  private ChangeData changeData;
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+  }
+
+  @Test
+  public void invalidExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("invalid_field:invalid_value");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.ERROR);
+    assertThat(result.errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void expressionWithPassingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/master");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void expressionWithFailingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/foo");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.FAIL);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void compositeExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create(
+            String.format(
+                "(project:%s AND branch:refs/heads/foo) OR message:\"Fix a bug\"", project.get()));
+
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+
+    assertThat(result.getPassingAtoms())
+        .containsExactly(
+            PredicateResult.builder()
+                .predicateString(String.format("project:%s", project.get()))
+                .status(true)
+                .build(),
+            PredicateResult.builder()
+                .predicateString("message:\"Fix a bug\"")
+                .status(true)
+                .build());
+
+    assertThat(result.getFailingAtoms())
+        .containsExactly(
+            PredicateResult.builder()
+                // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
+                // match
+                .predicateString(String.format("ref:refs/heads/foo"))
+                .status(false)
+                .build());
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix a bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsUnsatisfied_whenSubmittabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsTrue() throws Exception {
+    addLabel("build-cop-override");
+    voteLabel(changeId, "build-cop-override", 1);
+
+    // Reload change data after applying the vote
+    changeData =
+        changeQueryProvider.get().byLegacyChangeId(changeData.getId()).stream()
+            .collect(MoreCollectors.onlyElement());
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void submitRequirementIsError_whenApplicabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "invalid_field:invalid_value",
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.applicabilityExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenSubmittabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "invalid_field:invalid_value",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenOverrideExpressionHasInvalidSyntax() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "invalid_field:invalid_value");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.overrideExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void addLabel(String labelName) throws Exception {
+    configLabel(
+        project,
+        labelName,
+        LabelFunction.NO_OP,
+        value(1, "ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"));
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(labelName).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr) {
+    return SubmitRequirement.builder()
+        .setName("sr-name")
+        .setDescription(Optional.of("sr-description"))
+        .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+        .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+        .setAllowOverrideInChildProjects(false)
+        .build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index c2e8e0c..a1dee1a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -29,7 +29,7 @@
           .setName("code-review")
           .setDescription(Optional.of("require code review +2"))
           .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
-          .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
           .setOverrideExpression(Optional.empty())
           .setAllowOverrideInChildProjects(true)
           .build();
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
new file mode 100644
index 0000000..f105cf1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotesCommitTest extends AbstractChangeNotesTest {
+  private TestRepository<InMemoryRepository> testRepo;
+  private ChangeNotesCommit.ChangeNotesRevWalk walk;
+
+  @Before
+  public void setUpTestRepo() throws Exception {
+    testRepo = new TestRepository<>(repo);
+    walk = ChangeNotesCommit.newRevWalk(repo);
+  }
+
+  @After
+  public void tearDownTestRepo() throws Exception {
+    walk.close();
+  }
+
+  @Test
+  public void attentionSetCommitOnlyWhenNoChangeMessageIsPresentAndCorrectFooter()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(true);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndFooterNotOnlyAS()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndGenericFooter()
+      throws Exception {
+    RevCommit commit = writeCommit("Update patch set 1\n" + "\n" + "Patch-set: 1\n");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenChangeMessageIsPresent() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(true)).isEqualTo(false);
+  }
+
+  private ChangeNotesParser newParser(ObjectId tip) throws Exception {
+    walk.reset();
+    ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+  }
+
+  private RevCommit writeCommit(String body) throws Exception {
+    Change change = newChange(true);
+    ChangeNotes notes = newNotes(change).load();
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    PersonIdent author =
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(notes.getRevision());
+      cb.setAuthor(author);
+      cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
+      cb.setTreeId(testRepo.tree());
+      cb.setMessage(body);
+      ObjectId id = ins.insert(cb);
+      ins.flush();
+      RevCommit commit = walk.parseCommit(id);
+      walk.parseBody(commit);
+      return commit;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 5bfe97c..6a32fa1 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -497,6 +497,73 @@
   }
 
   @Test
+  public void attentionSetOnlyShouldNotCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void attentionSetWithExtraFooterShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void changeWithoutAttentionSetShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void attentionSetWithCommentShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = true;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
   public void caseInsensitiveFooters() throws Exception {
     assertParseSucceeds(
         "Update change\n"
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
new file mode 100644
index 0000000..e4c2196
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+
+public class OpenRepoTest extends AbstractChangeNotesTest {
+
+  private final Optional<Integer> NO_UPDATES_AT_ALL = Optional.of(0);
+  private final Optional<Integer> ONLY_ONE_UPDATE = Optional.of(1);
+  private final Optional<Integer> ONLY_TWO_UPDATES = Optional.of(2);
+  private final Optional<Integer> MAX_PATCH_SETS = Optional.empty();
+
+  private FakeChainedReceiveCommands fakeChainedReceiveCommands;
+
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    super.setUpTestEnvironment();
+    fakeChainedReceiveCommands = new FakeChainedReceiveCommands(repo);
+  }
+
+  @Test
+  public void throwExceptionWhenExceedingMaxUpdatesLimit() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenAttentionSetUpdateOnly() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      // Add to attention set
+      AttentionSetUpdate attentionSetUpdate =
+          AttentionSetUpdate.createForWrite(
+              otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+      update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void attentionSetUpdateShouldNotContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      // Add to attention set
+      AttentionSetUpdate attentionSetUpdate =
+          AttentionSetUpdate.createForWrite(
+              otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+      update1.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      openRepo.addUpdates(changeUpdates, ONLY_TWO_UPDATES, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void normalChangeShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, ONLY_ONE_UPDATE, MAX_PATCH_SETS));
+    }
+  }
+
+  private static class FakeChainedReceiveCommands extends ChainedReceiveCommands {
+    Map<String, ReceiveCommand> commands = new HashMap<>();
+
+    public FakeChainedReceiveCommands(Repository repo) {
+      super(repo);
+    }
+
+    @Override
+    public void add(ReceiveCommand cmd) {
+      commands.put(cmd.getRefName(), cmd);
+    }
+  }
+
+  private OpenRepo openRepo() {
+    return new OpenRepo(repo, rw, null, fakeChainedReceiveCommands, false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 9dad9ae..2e934e9 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -212,11 +212,11 @@
                 "[submitRequirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "[submitRequirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
                     + "  applicabilityExpression = commit_filepath_contains(\\\"/api/.*\\\")\n"
-                    + "  blockingExpression = label(api-review, +2)\n"
+                    + "  submittabilityExpression = label(api-review, +2)\n"
                     + "  overrideExpression = label(build-cop-override, +1)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
@@ -231,7 +231,8 @@
                 .setDescription(Optional.of("At least one Code Review +2"))
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("branch(refs/heads/master)"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setOverrideExpression(Optional.empty())
                 .setAllowOverrideInChildProjects(false)
                 .build(),
@@ -241,7 +242,8 @@
                 .setDescription(Optional.of("Additional review required for API modifications"))
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("commit_filepath_contains(\"/api/.*\")"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(api-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(api-review, +2)"))
                 .setOverrideExpression(
                     SubmitRequirementExpression.of("label(build-cop-override, +1)"))
                 .setAllowOverrideInChildProjects(true)
@@ -256,7 +258,7 @@
             .add(
                 "project.config",
                 "[submitRequirement \"code-review\"]\n"
-                    + "  blockingExpression = label(code-review, +2)\n")
+                    + "  submittabilityExpression = label(code-review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -266,7 +268,8 @@
             "code-review",
             SubmitRequirement.builder()
                 .setName("code-review")
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
   }
@@ -280,10 +283,10 @@
                 "project.config",
                 "[submitRequirement \"code-review\"]\n"
                     + "  description = At least one Code Review +2\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "[submitRequirement \"Code-Review\"]\n"
                     + "  description = Another code review label\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
 
@@ -295,7 +298,8 @@
             SubmitRequirement.builder()
                 .setName("code-review")
                 .setDescription(Optional.of("At least one Code Review +2"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
     assertThat(cfg.getValidationErrors()).hasSize(1);
@@ -307,7 +311,7 @@
   }
 
   @Test
-  public void readSubmitRequirementNoBlockingExpression() throws Exception {
+  public void readSubmitRequirementNoSubmittabilityExpression() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("groups", group(developers))
@@ -323,7 +327,7 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: Submit requirement \"code-review\" does not define a blocking expression."
+            "project.config: Submit requirement \"code-review\" does not define a submittability expression."
                 + " Skipping this requirement.");
   }
 
@@ -942,7 +946,7 @@
                 "[submitRequirement \"code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
diff --git a/plugins/replication b/plugins/replication
index fd593ee..dc9bb2e 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit fd593ee1d3cb19d73c49f94c0f9b4a78856d6198
+Subproject commit dc9bb2e946e4c6c31e8a4665f30eca6d00017523
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0738825..d22026a 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -90,3 +90,9 @@
   // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
   FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
 }
+
+export enum Interaction {
+  TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
+  SHOW_TAB = 'show-tab',
+  ATTENTION_SET_CHIP = 'attention-set-chip',
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index 8a4cbbe..a9de24a 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -118,19 +118,15 @@
     assert.isTrue(saveStub.called);
   });
 
-  test('_getRepoBranchesSuggestions empty', done => {
-    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getRepoBranchesSuggestions empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('nonexistent');
+    assert.equal(branches.length, 0);
   });
 
-  test('_getRepoBranchesSuggestions non-empty', done => {
-    element._getRepoBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
+  test('_getRepoBranchesSuggestions non-empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('test-branch');
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
   });
 
   test('_computeBranchClass', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index c8e058e..4864af5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -23,20 +23,22 @@
 
 const basicFixture = fixtureFromElement('gr-repo-list');
 
-let counter;
-const repoGenerator = () => {
+function createRepo(name, counter) {
   return {
-    id: `test${++counter}`,
-    name: `test`,
+    id: `${name}${counter}`,
+    name: `${name}`,
     state: 'ACTIVE',
     web_links: [
       {
         name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/test${counter}`,
+        url: `https://phabricator.example.org/r/project/${name}${counter}`,
       },
     ],
   };
-};
+}
+
+let counter;
+const repoGenerator = () => createRepo('test', ++counter);
 
 suite('gr-repo-list tests', () => {
   let element;
@@ -123,6 +125,15 @@
         done();
       });
     });
+
+    test('filter is case insensitive', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const repos = [createRepo('aSDf', 0)];
+      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
+      element._filter = 'asdf';
+      await element._getRepos('asdf', 25, 0);
+      assert.equal(element._repos.length, 1);
+    });
   });
 
   suite('loading', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index d6f4bdd..41a7598 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -252,7 +252,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this._computeTitle(user));
-        console.warn(err);
+        this.reporting.error(err);
       })
       .then(() => {
         this._loading = false;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 1924a66..42a6847 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -29,7 +29,7 @@
   </style>
   <gr-avatar
     account="[[_accountDetails]]"
-    image-size="100"
+    imageSize="100"
     aria-label="Account avatar"
   ></gr-avatar>
   <div class="info">
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 1790651..02e4161 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -30,6 +30,7 @@
 } from '../../../test/test-data-generators';
 import {ChangeStatus, HttpMethod} from '../../../constants/constants';
 import {
+  mockPromise,
   query,
   queryAll,
   queryAndAssert,
@@ -144,13 +145,11 @@
       return element.reload();
     });
 
-    test('show-revision-actions event should fire', done => {
+    test('show-revision-actions event should fire', async () => {
       const spy = sinon.spy(element, '_sendShowRevisionActions');
       element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
+      await flush();
+      assert.isTrue(spy.called);
     });
 
     test('primary and secondary actions split properly', () => {
@@ -198,7 +197,7 @@
       );
     });
 
-    test('plugin revision actions', done => {
+    test('plugin revision actions', async () => {
       const stub = stubRestApi('getChangeActionURL').returns(
         Promise.resolve('the-url')
       );
@@ -206,20 +205,18 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(
-          stub.calledWith(
-            element.changeNum,
-            element.latestPatchNum,
-            '/plugin~action'
-          )
-        );
-        assert.equal(
-          (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
-          'the-url'
-        );
-        done();
-      });
+      await flush();
+      assert.isTrue(
+        stub.calledWith(
+          element.changeNum,
+          element.latestPatchNum,
+          '/plugin~action'
+        )
+      );
+      assert.equal(
+        (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
+        'the-url'
+      );
     });
 
     test('plugin change actions', async () => {
@@ -266,71 +263,61 @@
       );
     });
 
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        element.setActionHidden(
-          element.ActionType.REVISION,
-          element.RevisionActions.SUBMIT,
-          true
-        );
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(
-          element.ActionType.REVISION,
-          element.RevisionActions.SUBMIT,
-          true
-        );
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot?.querySelector(
-            '[data-action-key="submit"]'
-          );
-          assert.isNotOk(buttonEl);
+    test('hide revision action', async () => {
+      await flush();
+      let buttonEl: Element | undefined = queryAndAssert(
+        element,
+        '[data-action-key="submit"]'
+      );
+      assert.isOk(buttonEl);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      await flush();
+      buttonEl = query(element, '[data-action-key="submit"]');
+      assert.isNotOk(buttonEl);
 
-          element.setActionHidden(
-            element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT,
-            false
-          );
-          flush(() => {
-            const buttonEl = queryAndAssert(
-              element,
-              '[data-action-key="submit"]'
-            );
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        false
+      );
+      await flush();
+      buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
+      assert.isFalse(buttonEl.hasAttribute('hidden'));
     });
 
-    test('buttons exist', done => {
+    test('buttons exist', async () => {
       element._loading = false;
-      flush(() => {
-        const buttonEls = queryAll(element, 'gr-button');
-        const menuItems = element.$.moreActions.items;
+      await flush();
+      const buttonEls = queryAll(element, 'gr-button');
+      const menuItems = element.$.moreActions.items;
 
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(
-          buttonEls!.length + menuItems!.length,
-          element._allActionValues.length + 1
-        );
-        assert.isFalse(element.hidden);
-        done();
-      });
+      // Total button number is one greater than the number of total actions
+      // due to the existence of the overflow menu trigger.
+      assert.equal(
+        buttonEls!.length + menuItems!.length,
+        element._allActionValues.length + 1
+      );
+      assert.isFalse(element.hidden);
     });
 
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items!.filter(item =>
-          item.id!.startsWith('delete')
-        );
-        assert.equal(deleteItems.length, 1);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
+    test('delete buttons have explicit labels', async () => {
+      await flush();
+      const deleteItems = element.$.moreActions.items!.filter(item =>
+        item.id!.startsWith('delete')
+      );
+      assert.equal(deleteItems.length, 1);
+      assert.equal(deleteItems[0].name, 'Delete change');
     });
 
     test('get revision object from change', () => {
@@ -369,7 +356,7 @@
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', () => {
+    test('submit change', async () => {
       const showSpy = sinon.spy(element, '_showActionDialog');
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
@@ -390,12 +377,15 @@
       );
       tap(submitButton);
 
-      flush();
+      await flush();
       assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
     });
 
-    test('submit change, tap on icon', done => {
-      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake(done);
+    test('submit change, tap on icon', async () => {
+      const submitted = mockPromise();
+      sinon
+        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .callsFake(() => submitted.resolve());
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
@@ -414,6 +404,7 @@
         'gr-button[data-action-key="submit"] iron-icon'
       );
       tap(submitIcon);
+      await submitted;
     });
 
     test('_handleSubmitConfirm', () => {
@@ -435,19 +426,16 @@
       assert.isFalse(fireStub.called);
     });
 
-    test('submit change with plugin hook', done => {
+    test('submit change with plugin hook', async () => {
       sinon.stub(element, '_canSubmitChange').callsFake(() => false);
       const fireActionStub = sinon.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = queryAndAssert(
-          element,
-          'gr-button[data-action-key="submit"]'
-        );
-        tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
+      await flush();
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+      assert.equal(fireActionStub.callCount, 0);
     });
 
     test('chain state', () => {
@@ -490,55 +478,51 @@
       );
     });
 
-    test('rebase change', done => {
+    test('rebase change', async () => {
       const fireActionStub = sinon.stub(element, '_fireAction');
       const fetchChangesStub = sinon
         .stub(element.$.confirmRebase, 'fetchRecentChanges')
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = queryAndAssert(
-          element,
-          'gr-button[data-action-key="rebase"]'
-        );
-        tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: HttpMethod.POST,
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm(
-          new CustomEvent('', {detail: {base: '1234'}})
-        );
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/rebase',
-          assertUIActionInfo(rebaseAction),
-          true,
-          {base: '1234'},
-        ]);
-        done();
-      });
+      await flush();
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      const rebaseAction = {
+        __key: 'rebase',
+        __type: 'revision',
+        __primary: false,
+        enabled: true,
+        label: 'Rebase',
+        method: HttpMethod.POST,
+        title: 'Rebase onto tip of branch or parent change',
+      };
+      assert.isTrue(fetchChangesStub.called);
+      element._handleRebaseConfirm(
+        new CustomEvent('', {detail: {base: '1234'}})
+      );
+      assert.deepEqual(fireActionStub.lastCall.args, [
+        '/rebase',
+        assertUIActionInfo(rebaseAction),
+        true,
+        {base: '1234'},
+      ]);
     });
 
-    test('rebase change fires reload event', done => {
+    test('rebase change fires reload event', async () => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
       element._handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      flush(() => {
-        assert.isTrue(eventStub.called);
-        assert.equal(eventStub.lastCall.args[0].type, 'reload');
-        done();
-      });
+      await flush();
+      assert.isTrue(eventStub.called);
+      assert.equal(eventStub.lastCall.args[0].type, 'reload');
     });
 
-    test("rebase dialog gets recent changes each time it's opened", done => {
+    test("rebase dialog gets recent changes each time it's opened", async () => {
       const fetchChangesStub = sinon
         .stub(element.$.confirmRebase, 'fetchRecentChanges')
         .returns(Promise.resolve([]));
@@ -550,17 +534,15 @@
       tap(rebaseButton);
       assert.isTrue(fetchChangesStub.calledOnce);
 
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true,
-            bubbles: true,
-          })
-        );
-        tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
+      await flush();
+      element.$.confirmRebase.dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledTwice);
     });
 
     test('two dialogs are not shown at the same time', async () => {
@@ -624,7 +606,7 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', () => {
+      test('disableEdit', async () => {
         element.set('editMode', false);
         element.set('editPatchsetLoaded', false);
         element.change = {
@@ -632,7 +614,7 @@
           status: ChangeStatus.NEW,
         };
         element.set('disableEdit', true);
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -647,7 +629,7 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('shows confirm dialog for delete edit', () => {
+      test('shows confirm dialog for delete edit', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
 
@@ -660,12 +642,12 @@
             'gr-button[primary]'
           )
         );
-        flush();
+        await flush();
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
       });
 
-      test('edit patchset is loaded, needs rebase', () => {
+      test('edit patchset is loaded, needs rebase', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
         element.change = {
@@ -673,7 +655,7 @@
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = false;
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -684,7 +666,7 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit patchset is loaded, does not need rebase', () => {
+      test('edit patchset is loaded, does not need rebase', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
         element.change = {
@@ -692,7 +674,7 @@
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = true;
-        flush();
+        await flush();
 
         assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(
@@ -703,14 +685,14 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit mode is loaded, no edit patchset', () => {
+      test('edit mode is loaded, no edit patchset', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', false);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -725,14 +707,14 @@
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('normal patch set', () => {
+      test('normal patch set', async () => {
         element.set('editMode', false);
         element.set('editPatchsetLoaded', false);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -747,16 +729,17 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit action', done => {
+      test('edit action', async () => {
+        const editTapped = mockPromise();
         element.addEventListener('edit-tap', () => {
-          done();
+          editTapped.resolve();
         });
         element.set('editMode', true);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -764,7 +747,7 @@
           ...createChangeViewChange(),
           status: ChangeStatus.MERGED,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         element.change = {
@@ -772,13 +755,14 @@
           status: ChangeStatus.NEW,
         };
         element.set('editMode', false);
-        flush();
+        await flush();
 
         const editButton = queryAndAssert(
           element,
           'gr-button[data-action-key="edit"]'
         );
         tap(editButton);
+        await editTapped;
       });
     });
 
@@ -896,62 +880,57 @@
             status: ChangeStatus.NEW,
           },
         ];
-        setup(done => {
+        setup(async () => {
           stubRestApi('getChanges').returns(Promise.resolve(changes));
           element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = queryAll(
-              element.$.confirmCherrypick,
-              "input[name='cherryPickOptions']"
-            );
-            assert.equal(radioButtons.length, 2);
-            tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
+          await flush();
+          const radioButtons = queryAll(
+            element.$.confirmCherrypick,
+            "input[name='cherryPickOptions']"
+          );
+          assert.equal(radioButtons.length, 2);
+          tap(radioButtons[1]);
+          await flush();
         });
 
-        test('cherry pick topic dialog is rendered', done => {
+        test('cherry pick topic dialog is rendered', async () => {
           const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = queryAndAssert(dialog, 'table');
-            const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = [
-              '',
-              'Change',
-              'Status',
-              'Subject',
-              'Project',
-              'Progress',
-              '',
-            ];
-            const headings = headers.map(header => header.innerText);
-            assert.equal(headings.length, expectedHeadings.length);
-            for (let i = 0; i < headings.length; i++) {
-              assert.equal(headings[i].trim(), expectedHeadings[i]);
-            }
-            const changeRows = queryAll(changesTable, 'tbody > tr');
-            const change = Array.from(changeRows[0].querySelectorAll('td')).map(
-              e => e.innerText
-            );
-            const expectedChange = [
-              '',
-              '1234567890',
-              'MERGED',
-              'random',
-              'A',
-              'NOT STARTED',
-              '',
-            ];
-            for (let i = 0; i < change.length; i++) {
-              assert.equal(change[i].trim(), expectedChange[i]);
-            }
-            done();
-          });
+          await flush();
+          const changesTable = queryAndAssert(dialog, 'table');
+          const headers = Array.from(changesTable.querySelectorAll('th'));
+          const expectedHeadings = [
+            '',
+            'Change',
+            'Status',
+            'Subject',
+            'Project',
+            'Progress',
+            '',
+          ];
+          const headings = headers.map(header => header.innerText);
+          assert.equal(headings.length, expectedHeadings.length);
+          for (let i = 0; i < headings.length; i++) {
+            assert.equal(headings[i].trim(), expectedHeadings[i]);
+          }
+          const changeRows = queryAll(changesTable, 'tbody > tr');
+          const change = Array.from(changeRows[0].querySelectorAll('td')).map(
+            e => e.innerText
+          );
+          const expectedChange = [
+            '',
+            '1234567890',
+            'MERGED',
+            'random',
+            'A',
+            'NOT STARTED',
+            '',
+          ];
+          for (let i = 0; i < change.length; i++) {
+            assert.equal(change[i].trim(), expectedChange[i]);
+          }
         });
 
-        test('changes with duplicate project show an error', done => {
+        test('changes with duplicate project show an error', async () => {
           const dialog = element.$.confirmCherrypick;
           const error = queryAndAssert(
             dialog,
@@ -974,13 +953,11 @@
               project: 'A' as RepoName,
             },
           ]);
-          flush(() => {
-            assert.equal(
-              error.innerText,
-              'Two changes cannot be of the same' + ' project'
-            );
-            done();
-          });
+          await flush();
+          assert.equal(
+            error.innerText,
+            'Two changes cannot be of the same' + ' project'
+          );
         });
       });
     });
@@ -1021,24 +998,24 @@
       });
     });
 
-    test('custom actions', done => {
+    test('custom actions', async () => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
       const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
+      const keyTapped = mockPromise();
+      element.addEventListener(key + '-tap', async e => {
         assert.equal(
           (e as CustomEvent).detail.node.getAttribute('data-action-key'),
           key
         );
         element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(query(element, '[data-action-key="' + key + '"]'));
-          done();
-        });
+        await flush();
+        assert.notOk(query(element, '[data-action-key="' + key + '"]'));
+        keyTapped.resolve();
       });
-      flush(() => {
-        tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
-      });
+      await flush();
+      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await keyTapped;
     });
 
     test('_setLoadingOnButtonWithKey top-level', () => {
@@ -1095,32 +1072,28 @@
         return element.reload();
       });
 
-      test('abandon change with message', done => {
+      test('abandon change with message', async () => {
         const newAbandonMsg = 'Test Abandon Message';
         element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="abandon"]'
-          );
-          tap(abandonButton);
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
 
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
+        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
       });
 
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="abandon"]'
-          );
-          tap(abandonButton);
+      test('abandon change with no message', async () => {
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
 
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
+        assert.isUndefined(element.$.confirmAbandonDialog.message);
       });
 
       test('works', () => {
@@ -1173,7 +1146,7 @@
         return element.reload();
       });
 
-      test('revert change with plugin hook', done => {
+      test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
           .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
@@ -1204,17 +1177,14 @@
             '_populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
-        flush(() => {
-          const revertButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="revert"]'
-          );
-          tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
+        await flush();
+        const revertButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="revert"]'
+        );
+        tap(revertButton);
+        await flush();
+        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
@@ -1243,60 +1213,56 @@
           );
         });
 
-        test('confirm revert dialog shows both options', done => {
+        test('confirm revert dialog shows both options', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = queryAndAssert(
-              confirmRevertDialog,
-              '.revertSingleChange'
-            ) as HTMLLabelElement;
-            const revertSubmissionLabel = queryAndAssert(
-              confirmRevertDialog,
-              '.revertSubmission'
-            ) as HTMLLabelElement;
-            assert(
-              revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change'
-            );
-            assert(
-              revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)'
-            );
-            let expectedMsg =
-              'Revert submission 199 0' +
-              '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' +
-              '\n' +
-              'Reverted Changes:' +
-              '\n' +
-              '1234567890:random' +
-              '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg =
-                'Revert "random commit message"\n\nThis reverts ' +
-                'commit 2000.\n\nReason' +
-                ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
+          await flush();
+          assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const revertSingleChangeLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSingleChange'
+          ) as HTMLLabelElement;
+          const revertSubmissionLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSubmission'
+          ) as HTMLLabelElement;
+          assert(
+            revertSingleChangeLabel.innerText.trim() === 'Revert single change'
+          );
+          assert(
+            revertSubmissionLabel.innerText.trim() ===
+              'Revert entire submission (2 Changes)'
+          );
+          let expectedMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          tap(radioInputs[0]);
+          await flush();
+          expectedMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
         });
 
-        test('submit fails if message is not edited', done => {
+        test('submit fails if message is not edited', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
@@ -1304,69 +1270,58 @@
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
         });
 
-        test('message modification is retained on switching', done => {
+        test('message modification is retained on switching', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
-          flush(() => {
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            const revertSubmissionMsg =
-              'Revert submission 199 0' +
-              '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' +
-              '\n' +
-              'Reverted Changes:' +
-              '\n' +
-              '1234567890:random' +
-              '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const singleChangeMsg =
-              'Revert "random commit message"\n\nThis reverts ' +
-              'commit 2000.\n\nReason' +
-              ' for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-            const newRevertMsg = revertSubmissionMsg + 'random';
-            const newSingleChangeMsg = singleChangeMsg + 'random';
-            confirmRevertDialog._message = newRevertMsg;
-            tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                    confirmRevertDialog._message,
-                    newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
+          await flush();
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          const revertSubmissionMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          const newRevertMsg = revertSubmissionMsg + 'random';
+          const newSingleChangeMsg = singleChangeMsg + 'random';
+          confirmRevertDialog._message = newRevertMsg;
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, singleChangeMsg);
+          confirmRevertDialog._message = newSingleChangeMsg;
+          tap(radioInputs[1]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newRevertMsg);
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
         });
       });
 
@@ -1389,7 +1344,7 @@
           );
         });
 
-        test('submit fails if message is not edited', done => {
+        test('submit fails if message is not edited', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
@@ -1397,55 +1352,46 @@
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
         });
 
-        test('confirm revert dialog shows no radio button', done => {
+        test('confirm revert dialog shows no radio button', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            assert.equal(radioInputs.length, 0);
-            const msg =
-              'Revert "random commit message"\n\n' +
-              'This reverts commit 2000.\n\nReason ' +
-              'for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, msg);
-            const editedMsg = msg + 'hello';
-            confirmRevertDialog._message += 'hello';
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-              assert.equal(
-                fireActionStub.getCall(0).args[3].message,
-                editedMsg
-              );
-              done();
-            });
-          });
+          await flush();
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          assert.equal(radioInputs.length, 0);
+          const msg =
+            'Revert "random commit message"\n\n' +
+            'This reverts commit 2000.\n\nReason ' +
+            'for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, msg);
+          const editedMsg = msg + 'hello';
+          confirmRevertDialog._message += 'hello';
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+          assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+          assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
         });
       });
     });
@@ -1477,27 +1423,23 @@
       test(
         'make sure the mark private change button is not outside of the ' +
           'overflow menu',
-        done => {
-          flush(() => {
-            assert.isNotOk(query(element, '[data-action-key="private"]'));
-            done();
-          });
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private"]'));
         }
       );
 
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-            query(element.$.moreActions, 'span[data-id="private-change"]')
-          );
-          element.setActionOverflow(ActionType.CHANGE, 'private', false);
-          flush();
-          assert.isOk(query(element, '[data-action-key="private"]'));
-          assert.isNotOk(
-            query(element.$.moreActions, 'span[data-id="private-change"]')
-          );
-          done();
-        });
+      test('private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
       });
     });
 
@@ -1528,35 +1470,23 @@
       test(
         'make sure the unmark private change button is not outside of the ' +
           'overflow menu',
-        done => {
-          flush(() => {
-            assert.isNotOk(
-              query(element, '[data-action-key="private.delete"]')
-            );
-            done();
-          });
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
         }
       );
 
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-            query(
-              element.$.moreActions,
-              'span[data-id="private.delete-change"]'
-            )
-          );
-          element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
-          flush();
-          assert.isOk(query(element, '[data-action-key="private.delete"]'));
-          assert.isNotOk(
-            query(
-              element.$.moreActions,
-              'span[data-id="private.delete-change"]'
-            )
-          );
-          done();
-        });
+      test('unmark the private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private.delete"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
       });
     });
 
@@ -1586,7 +1516,7 @@
         assert.isFalse(fireActionStub.called);
       });
 
-      test('shows confirm dialog', () => {
+      test('shows confirm dialog', async () => {
         element._handleDeleteTap();
         assert.isFalse(
           (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
@@ -1597,11 +1527,11 @@
             'gr-button[primary]'
           )
         );
-        flush();
+        await flush();
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
-      test('hides delete confirm on cancel', () => {
+      test('hides delete confirm on cancel', async () => {
         element._handleDeleteTap();
         tap(
           queryAndAssert(
@@ -1609,7 +1539,7 @@
             'gr-button:not([primary])'
           )
         );
-        flush();
+        await flush();
         assert.isTrue(
           (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
         );
@@ -1618,7 +1548,7 @@
     });
 
     suite('ignore change', () => {
-      setup(done => {
+      setup(async () => {
         sinon.stub(element, '_fireAction');
 
         const IgnoreAction = {
@@ -1638,19 +1568,18 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        element.reload().then(() => {
-          flush(done);
-        });
+        await element.reload();
+        await flush();
       });
 
       test('make sure the ignore button is not outside of the overflow menu', () => {
         assert.isNotOk(query(element, '[data-action-key="ignore"]'));
       });
 
-      test('ignoring change', () => {
+      test('ignoring change', async () => {
         queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
         element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
-        flush();
+        await flush();
         queryAndAssert(element, '[data-action-key="ignore"]');
         assert.isNotOk(
           query(element.$.moreActions, 'span[data-id="ignore-change"]')
@@ -1659,7 +1588,7 @@
     });
 
     suite('unignore change', () => {
-      setup(done => {
+      setup(async () => {
         sinon.stub(element, '_fireAction');
 
         const UnignoreAction = {
@@ -1679,21 +1608,20 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        element.reload().then(() => {
-          flush(done);
-        });
+        await element.reload();
+        await flush();
       });
 
       test('unignore button is not outside of the overflow menu', () => {
         assert.isNotOk(query(element, '[data-action-key="unignore"]'));
       });
 
-      test('unignoring change', () => {
+      test('unignoring change', async () => {
         assert.isOk(
           query(element.$.moreActions, 'span[data-id="unignore-change"]')
         );
         element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
-        flush();
+        await flush();
         assert.isOk(query(element, '[data-action-key="unignore"]'));
         assert.isNotOk(
           query(element.$.moreActions, 'span[data-id="unignore-change"]')
@@ -1702,7 +1630,7 @@
     });
 
     suite('quick approve', () => {
-      setup(() => {
+      setup(async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1719,7 +1647,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        flush();
+        await flush();
       });
 
       test('added when can approve', () => {
@@ -1730,7 +1658,7 @@
         assert.isNotNull(approveButton);
       });
 
-      test('hide quick approve', () => {
+      test('hide quick approve', async () => {
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1740,7 +1668,7 @@
 
         // Assert approve button gets removed from list of buttons.
         element.hideQuickApproveAction();
-        flush();
+        await flush();
         const approveButtonUpdated = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1756,18 +1684,21 @@
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
 
-      test('not added when change is merged', () => {
-        element.change!.status = ChangeStatus.MERGED;
-        flush(() => {
-          const approveButton = query(
-            element,
-            "gr-button[data-action-key='review']"
-          );
-          assert.isNotOk(approveButton);
-        });
+      test('not added when change is merged', async () => {
+        element.change = {
+          ...element.change!,
+          status: ChangeStatus.MERGED,
+        };
+
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
       });
 
-      test('not added when already approved', () => {
+      test('not added when already approved', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1781,7 +1712,7 @@
             foo: [' 0', '+1'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1789,7 +1720,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('not added when label not permitted', () => {
+      test('not added when label not permitted', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1800,7 +1731,7 @@
             bar: [],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1808,17 +1739,17 @@
         assert.isNotOk(approveButton);
       });
 
-      test('approves when tapped', () => {
+      test('approves when tapped', async () => {
         const fireActionStub = sinon.stub(element, '_fireAction');
         tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
-        flush();
+        await flush();
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
         assert.deepEqual((payload as ReviewInput).labels, {foo: 1});
       });
 
-      test('not added when multiple labels are required without code review', () => {
+      test('not added when multiple labels are required without code review', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1831,7 +1762,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1839,7 +1770,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('code review shown with multiple missing approval', () => {
+      test('code review shown with multiple missing approval', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1861,7 +1792,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1869,7 +1800,7 @@
         assert.isOk(approveButton);
       });
 
-      test('button label for missing approval', () => {
+      test('button label for missing approval', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1887,7 +1818,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1895,7 +1826,7 @@
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('no quick approve if score is not maximal for a label', () => {
+      test('no quick approve if score is not maximal for a label', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1913,7 +1844,7 @@
             bar: [' 0', '+1'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1921,7 +1852,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('approving label with a non-max score', () => {
+      test('approving label with a non-max score', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1939,7 +1870,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1947,7 +1878,7 @@
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
 
-      test('added when can approve an already-approved code review label', () => {
+      test('added when can approve an already-approved code review label', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1965,7 +1896,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1973,7 +1904,7 @@
         assert.isNotNull(approveButton);
       });
 
-      test('not added when the user has already approved', () => {
+      test('not added when the user has already approved', async () => {
         const vote = {
           ...createApproval(),
           _account_id: 123 as AccountId,
@@ -1998,7 +1929,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -2006,7 +1937,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('not added when user owns the change', () => {
+      test('not added when user owns the change', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -2025,7 +1956,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -2034,12 +1965,12 @@
       });
     });
 
-    test('adds download revision action', () => {
+    test('adds download revision action', async () => {
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
       assert.ok(element.revisionActions.download);
       element._handleDownloadTap();
-      flush();
+      await flush();
 
       assert.isTrue(handler.called);
     });
@@ -2061,14 +1992,14 @@
     });
 
     suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
+      test('move action from overflow', async () => {
         assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
         assert.strictEqual(
           element.$.moreActions!.items![0].id,
           'cherrypick-revision'
         );
         element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
-        flush();
+        await flush();
         assert.isOk(query(element, '[data-action-key="cherrypick"]'));
         assert.notEqual(
           element.$.moreActions!.items![0].id,
@@ -2076,10 +2007,10 @@
         );
       });
 
-      test('move action to overflow', () => {
+      test('move action to overflow', async () => {
         assert.isOk(query(element, '[data-action-key="submit"]'));
         element.setActionOverflow(ActionType.REVISION, 'submit', true);
-        flush();
+        await flush();
         assert.isNotOk(query(element, '[data-action-key="submit"]'));
         assert.strictEqual(
           element.$.moreActions.items![3].id,
@@ -2233,31 +2164,24 @@
             );
           });
 
-          test('revert submission single change', done => {
-            element
-              ._send(
-                HttpMethod.POST,
-                {message: 'Revert submission'},
-                '/revert_submission',
-                false,
-                cleanup,
-                {} as UIActionInfo
-              )
-              .then(() => {
-                element
-                  ._handleResponse(
-                    {
-                      __key: 'revert_submission',
-                      __type: ActionType.CHANGE,
-                      label: 'l',
-                    },
-                    new Response()
-                  )!
-                  .then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-              });
+          test('revert submission single change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isTrue(navigateToSearchQueryStub.called);
           });
         });
 
@@ -2280,55 +2204,42 @@
             );
           });
 
-          test('revert submission multiple change', done => {
-            element
-              ._send(
-                HttpMethod.POST,
-                {message: 'Revert submission'},
-                '/revert_submission',
-                false,
-                cleanup,
-                {} as UIActionInfo
-              )
-              .then(() => {
-                element
-                  ._handleResponse(
-                    {
-                      __key: 'revert_submission',
-                      __type: ActionType.CHANGE,
-                      label: 'l',
-                    },
-                    new Response()
-                  )!
-                  .then(() => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(
-                      navigateToSearchQueryStub.calledWith('topic: T')
-                    );
-                    done();
-                  });
-              });
+          test('revert submission multiple change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isFalse(showActionDialogStub.called);
+            assert.isTrue(navigateToSearchQueryStub.calledWith('topic: T'));
           });
         });
 
-        test('revision action', done => {
-          element
-            ._send(
-              HttpMethod.DELETE,
-              payload,
-              '/endpoint',
-              true,
-              cleanup,
-              {} as UIActionInfo
-            )
-            .then(() => {
-              assert.isFalse(onShowError.called);
-              assert.isTrue(cleanup.calledOnce);
-              assert.isTrue(
-                sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
-              );
-              done();
-            });
+        test('revision action', async () => {
+          await element._send(
+            HttpMethod.DELETE,
+            payload,
+            '/endpoint',
+            true,
+            cleanup,
+            {} as UIActionInfo
+          );
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(
+            sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
+          );
         });
       });
 
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 3d55097..e1a1cbb 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
@@ -86,6 +86,7 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -619,7 +620,7 @@
 
   _onShowAllClick() {
     this._showAllSections = !this._showAllSections;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'metadata',
       toState: this._showAllSections ? 'Show all' : 'Show less',
     });
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index 1d7db85..7fc3ddf 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -35,6 +35,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {labelCompare} from '../../../utils/label-util';
+import {Interaction} from '../../../constants/reporting';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -181,7 +182,7 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'optional labels',
       toState: this._showOptionalLabels ? 'Show all' : 'Show less',
     });
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 bd9103e..c34a6ae 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
@@ -327,10 +327,9 @@
           margin-bottom: var(--spacing-m);
         }
         .zeroState {
-          color: var(--primary-text-color);
+          color: var(--deemphasized-text-color);
         }
         .loading.zeroState {
-          color: var(--deemphasized-text-color);
           margin-right: var(--spacing-m);
         }
         div.error {
@@ -561,7 +560,7 @@
                   account =>
                     html`<gr-avatar
                       .account="${account}"
-                      image-size="32"
+                      imageSize="32"
                     ></gr-avatar>`
                 )}
                 ${countUnresolvedComments} unresolved</gr-summary-chip
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 659d5bd..58cf489 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -72,7 +72,7 @@
   hasEditPatchsetLoaded,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses} from '../../../utils/change-util';
+import {changeStatuses, isOwner, isReviewer} from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -172,7 +172,7 @@
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 
@@ -634,7 +634,7 @@
           this._dynamicTabContentEndpoints.length !==
           this._dynamicTabHeaderEndpoints.length
         ) {
-          console.warn('Different number of tab headers and tab content.');
+          this.reporting.error(new Error('Mismatch of headers and content.'));
         }
       })
       .then(() => this._initActiveTabs(this.params));
@@ -767,7 +767,7 @@
       }
     }
     if (activeIndex === -1) {
-      console.warn('tab not found with given info', activeDetails);
+      this.reporting.error(new Error(`tab not found for ${activeDetails}`));
       return;
     }
     const tabName = tabs[activeIndex].dataset['name'];
@@ -777,7 +777,7 @@
     if (paperTabs.selected !== activeIndex) {
       // paperTabs.selected is undefined during rendering
       if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction('show-tab', {tabName, src});
+        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
       }
       paperTabs.selected = activeIndex;
     }
@@ -840,7 +840,7 @@
       if (hasUnresolvedThreads) this.unresolvedOnly = true;
     }
 
-    this.reporting.reportInteraction('show-tab', {
+    this.reporting.reportInteraction(Interaction.SHOW_TAB, {
       tabName,
       src: 'paper-tab-click',
     });
@@ -1225,10 +1225,7 @@
     this._shownFileCount = e.detail.length;
   }
 
-  _expandAllDiffs(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _expandAllDiffs() {
     this.$.fileList.expandAllDiffs();
   }
 
@@ -2133,7 +2130,10 @@
       .then(() => {
         this.reporting.timeEnd(Timing.CHANGE_RELOAD);
         if (isLocationChange) {
-          this.reporting.changeDisplayed();
+          this.reporting.changeDisplayed({
+            isOwner: isOwner(this._change, this._account),
+            isReviewer: isReviewer(this._change, this._account),
+          });
         }
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 532097b..ff303e3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -44,6 +44,7 @@
 import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -60,7 +61,7 @@
 }
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends PolymerElement {
+export class GrFileListHeader extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 69be729..8b9b80c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -173,6 +173,7 @@
           class="download"
           title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
                 ShortcutSection.ACTIONS)]]"
+          has-tooltip=""
           on-click="_handleDownloadTap"
           >Download</gr-button
         >
@@ -186,6 +187,7 @@
           link=""
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
                 ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
           on-click="_expandAllDiffs"
           >Expand All</gr-button
         >
@@ -195,6 +197,7 @@
           on-click="_collapseAllDiffs"
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
           ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
           >Collapse All</gr-button
         >
       </template>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index f61e024..5ce6e3b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -385,25 +385,19 @@
           this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicPrependedHeaderEndpoints.length !==
           this._dynamicPrependedContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list headers and summary.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
       });
   }
@@ -1429,7 +1423,9 @@
         console.info('Expanding diff', iter, 'of', initialCount, ':', path);
         const diffElem = this._findDiffByPath(path, diffElements);
         if (!diffElem) {
-          console.warn(`Did not find <gr-diff-host> element for ${path}`);
+          this.reporting.error(
+            new Error(`Did not find <gr-diff-host> element for ${path}`)
+          );
           return Promise.resolve();
         }
         if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 4a41316..57c1a30 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -20,7 +20,7 @@
   <style include="gr-voting-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
       position: relative;
@@ -148,6 +148,7 @@
       vertical-align: top;
     }
     .score {
+      box-sizing: border-box;
       border-radius: var(--border-radius);
       color: var(--vote-text-color);
       display: inline-block;
@@ -199,6 +200,10 @@
         font-weight: var(--font-weight-bold);
       }
     }
+    iron-icon {
+      --iron-icon-height: 20px;
+      --iron-icon-width: 20px;
+    }
     @media screen and (max-width: 50em) {
       .expanded .content {
         padding-left: 0;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 6be0c07..4e2aaf1 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -270,7 +270,9 @@
       | undefined;
 
     if (!el && this._showAllActivity) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
+      this.reporting.error(
+        new Error(`Failed to scroll to message: ${messageID}`)
+      );
       return;
     }
     if (!el) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 9c9b21a..360d7de 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -40,6 +40,7 @@
   getRevisionKey,
   isChangeInfo,
 } from '../../../utils/change-util';
+import {Interaction} from '../../../constants/reporting';
 
 /** What is the maximum number of shown changes in collapsed list? */
 const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
@@ -746,7 +747,7 @@
   private toggle(e: MouseEvent) {
     e.stopPropagation();
     this.showAll = !this.showAll;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: this.title,
       toState: this.showAll ? 'Show all' : 'Show less',
     });
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 5504877..939b23c 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
@@ -113,7 +113,7 @@
 import {ErrorCallback} from '../../../api/rest';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -758,7 +758,7 @@
     if (changeReviewers) {
       for (const key of Object.keys(changeReviewers)) {
         if (key !== 'REVIEWER' && key !== 'CC') {
-          console.warn('unexpected reviewer state:', key);
+          this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
           continue;
         }
         if (!changeReviewers[key]) continue;
@@ -820,12 +820,12 @@
 
     if (this._newAttentionSet.has(id)) {
       this._newAttentionSet.delete(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
       this._newAttentionSet.add(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
@@ -1085,9 +1085,8 @@
       } else if (isReviewerGroupSuggestion(suggestion)) {
         entry = suggestion.group;
       } else {
-        console.warn(
-          'received suggestion that was neither account nor group:',
-          suggestion
+        this.reporting.error(
+          new Error(`Suggestion is neither account nor group: ${suggestion}`)
         );
         return false;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 17e5e26..c3c1d54 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -391,8 +391,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
+                  selection-chip-style
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -462,8 +462,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
               hide-hovercard=""
+              selection-chip-style
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -477,8 +477,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 hide-hovercard=""
+                selection-chip-style
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -496,9 +496,9 @@
               <gr-account-label
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]
                 hide-hovercard=""
+                selection-chip-style
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -518,8 +518,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
+                  selection-chip-style
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 2f4adf5..8283be79 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -112,7 +112,7 @@
   }
 
   _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments.' : 'No unresolved comments';
+    return !threads.length ? 'No comments' : 'No unresolved comments';
   }
 
   _showPartyPopper(threads: CommentThread[]) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 1fe00ff..3d6cfd4 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -611,7 +611,7 @@
     test('default empty message should show', () => {
       assert.isTrue(
           element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments.'));
+              .includes('No comments'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 53d25ec..7796a6e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -40,24 +40,24 @@
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
+  CheckRun,
+  checksSelectedPatchsetNumber$,
+  RunResult,
+  someProvidersAreLoadingSelected$,
   topLevelActionsSelected$,
   topLevelLinksSelected$,
-  CheckRun,
-  RunResult,
-  checksSelectedPatchsetNumber$,
-  someProvidersAreLoadingSelected$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
   fireActionTriggered,
+  firstPrimaryLink,
   hasCompletedWithoutResults,
   iconForCategory,
   iconForLink,
   otherPrimaryLinks,
-  firstPrimaryLink,
   primaryRunAction,
-  tooltipForLink,
   secondaryLinks,
+  tooltipForLink,
 } from '../../services/checks/checks-util';
 import {assertIsDefined, check} from '../../utils/common-util';
 import {toggleClass, whenVisible} from '../../utils/dom-util';
@@ -928,10 +928,14 @@
   private scrollElIntoView(selector: string) {
     this.updateComplete.then(() => {
       let el = this.shadowRoot?.querySelector(selector);
-      // <gr-result-row> has display:contents and cannot be scrolled into view
-      // itself. Thus we are preferring to scroll the first child into view.
-      el = el?.shadowRoot?.firstElementChild ?? el;
-      el?.scrollIntoView({block: 'center'});
+      // el might be a <gr-result-row> with an empty shadowRoot. Let's wait a
+      // moment before trying to find a child element in it.
+      setTimeout(() => {
+        // <gr-result-row> has display:contents and cannot be scrolled into view
+        // itself. Thus we are preferring to scroll the first child into view.
+        el = el?.shadowRoot?.firstElementChild ?? el;
+        el?.scrollIntoView({block: 'center'});
+      }, 0);
     });
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 963d28a..e7a7659 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -27,7 +27,7 @@
 } from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$} from '../../services/change/change-model';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -56,6 +56,9 @@
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
   changeNum: NumericChangeId | undefined = undefined;
 
   @state()
@@ -75,6 +78,7 @@
     this.subscribe('runs', allRunsSelectedPatchset$);
     this.subscribe('results', allResultsSelected$);
     this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
+    this.subscribe('latestPatchsetNumber', latestPatchNum$);
     this.subscribe('changeNum', changeNum$);
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
@@ -135,10 +139,11 @@
 
   handleActionTriggered(action: Action, run?: CheckRun) {
     if (!this.changeNum) return;
-    if (!this.checksPatchsetNumber) return;
+    const patchSet = this.checksPatchsetNumber ?? this.latestPatchsetNumber;
+    if (!patchSet) return;
     const promise = action.callback(
       this.changeNum,
-      this.checksPatchsetNumber,
+      patchSet,
       run?.attempt,
       run?.externalId,
       run?.checkName,
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
index 0fa2f1e..97f4a89 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -45,7 +45,7 @@
       account="[[account]]"
       hidden$="[[!_hasAvatars]]"
       hidden=""
-      image-size="56"
+      imageSize="56"
       aria-label="Account avatar"
     ></gr-avatar>
   </gr-dropdown>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 351e1bb..8f9dfe2 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -231,7 +231,7 @@
           );
         }
       }
-      console.info(`server error: ${errorText}`);
+      this.reporting.error(new Error(`Server error: ${errorText}`));
     });
   };
 
@@ -319,7 +319,7 @@
 
   private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
-    console.error(e.detail.error.message);
+    this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
   };
 
   // TODO(dhruvsr): allow less priority alerts to override high priority alerts
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
index 91f119e..fe4d9da 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -207,7 +207,6 @@
     });
 
     test('show network error', done => {
-      const consoleErrorStub = sinon.stub(console, 'error');
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
           new CustomEvent('network-error', {
@@ -218,8 +217,6 @@
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
deleted file mode 100644
index ccba289..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {css} from 'lit-element';
-
-export const cssTemplate = css`
-  .key {
-    background-color: var(--chip-background-color);
-    color: var(--primary-text-color);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    display: inline-block;
-    font-weight: var(--font-weight-bold);
-    padding: var(--spacing-xxs) var(--spacing-m);
-    text-align: center;
-  }
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 091684f..657d7cc 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -16,9 +16,7 @@
  */
 import {html} from 'lit-html';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement, property} from 'lit-element';
-import {cssTemplate} from './gr-key-binding-display.css';
-import {sharedStyles} from '../../../styles/shared-styles';
+import {css, customElement, property} from 'lit-element';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,7 +27,20 @@
 @customElement('gr-key-binding-display')
 export class GrKeyBindingDisplay extends GrLitElement {
   static get styles() {
-    return [sharedStyles, cssTemplate];
+    return [
+      css`
+        .key {
+          background-color: var(--chip-background-color);
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          display: inline-block;
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-xxs) var(--spacing-m);
+          text-align: center;
+        }
+      `,
+    ];
   }
 
   render() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index b42deda..72c7a62 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -398,7 +398,7 @@
       if (e instanceof Response) {
         this._handleGetDiffError(e);
       } else {
-        console.warn('Error encountered loading diff:', e);
+        this.reporting.error(e);
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL);
@@ -483,12 +483,12 @@
               });
             })
             .catch(err => {
-              console.warn('Applying coverage from provider failed: ', err);
+              this.reporting.error(err);
             });
         });
       })
       .catch(err => {
-        console.warn('Loading coverage ranges failed: ', err);
+        this.reporting.error(err);
       });
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index d626fe5..dacd8e7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -71,7 +71,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   FileInfo,
   NumericChangeId,
   ParentPatchSetNum,
@@ -275,6 +274,11 @@
   @property({type: Number})
   _focusLineNum?: number;
 
+  private getReviewedParams: {
+    changeNum?: NumericChangeId;
+    patchNum?: PatchSetNum;
+  } = {};
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -455,8 +459,13 @@
   _setReviewed(reviewed: boolean) {
     if (this._editMode) return;
     this.$.reviewed.checked = reviewed;
-    if (!this._patchRange?.patchNum) return;
+    if (!this._patchRange?.patchNum || !this._path) return;
+    const path = this._path;
+    if (reviewed) this._reviewedFiles.add(path);
+    else this._reviewedFiles.delete(path);
     this._saveReviewedState(reviewed).catch(err => {
+      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
+      else this._reviewedFiles.add(path);
       fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
@@ -896,31 +905,26 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum
-  ): Promise<Set<string>> {
-    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
-    return this.restApiService
-      .getReviewedFiles(changeNum, patchNum)
-      .then(files => {
-        this._reviewedFiles = new Set(files);
-        return this._reviewedFiles;
-      });
+  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
+    if (!changeNum || !patchNum) return;
+    if (
+      this.getReviewedParams.changeNum === changeNum &&
+      this.getReviewedParams.patchNum === patchNum
+    ) {
+      return;
+    }
+    this.getReviewedParams = {
+      changeNum,
+      patchNum,
+    };
+    this.restApiService.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+    });
   }
 
-  _getReviewedStatus(
-    editMode?: boolean,
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    if (editMode || !path) {
-      return Promise.resolve(false);
-    }
-    return this._getReviewedFiles(changeNum, patchNum).then(files =>
-      files.has(path)
-    );
+  _getReviewedStatus(path: string) {
+    if (this._editMode) return false;
+    return this._reviewedFiles.has(path);
   }
 
   _initLineOfInterestAndCursor(leftSide: boolean) {
@@ -1095,7 +1099,9 @@
     // the top-level change info view) and therefore undefined in `params`.
     // If route is of type /comment/<commentId>/ then no patchNum is present
     if (!value.patchNum && !value.commentLink) {
-      console.warn('invalid url, no patchNum found');
+      this.reporting.error(
+        new Error(`Invalid diff view URL, no patchNum found: ${value}`)
+      );
       return;
     }
 
@@ -1199,47 +1205,39 @@
     }
   }
 
-  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  @observe('_path', '_prefs', '_reviewedFiles')
   _setReviewedObserver(
+    path?: string,
+    prefs?: DiffPreferencesInfo,
+    reviewedFiles?: Set<string>
+  ) {
+    if (prefs === undefined) return;
+    if (path === undefined) return;
+    if (reviewedFiles === undefined) return;
+    if (prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+      this.$.reviewed.checked = this._getReviewedStatus(path);
+    } else {
+      this._setReviewed(true);
+    }
+  }
+
+  @observe('_loggedIn', '_changeNum', '_patchRange')
+  getReviewedFiles(
     _loggedIn?: boolean,
-    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
-    _prefs?: DiffPreferencesInfo,
-    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+    _changeNum?: NumericChangeId,
+    patchRange?: PatchRange
   ) {
     if (_loggedIn === undefined) return;
-    if (paramsRecord === undefined) return;
-    if (_prefs === undefined) return;
-    if (patchRangeRecord === undefined) return;
-    if (patchRangeRecord.base === undefined) return;
+    if (_changeNum === undefined) return;
+    if (patchRange === undefined) return;
 
-    const patchRange = patchRangeRecord.base;
     if (!_loggedIn) {
       return;
     }
 
-    if (_prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-
-      if (patchRange.patchNum) {
-        this._getReviewedStatus(
-          this._editMode,
-          this._changeNum,
-          patchRange.patchNum,
-          this._path
-        ).then((status: boolean) => {
-          this.$.reviewed.checked = status;
-        });
-      }
-      return;
-    }
-    // shift + m navigates to next unreviewed file so request list of reviewed
-    // files even if manual review is not set
     this._getReviewedFiles(this._changeNum, patchRange.patchNum);
-
-    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
-      this._setReviewed(true);
-    }
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 23cb936..ebcfa18 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -91,7 +91,6 @@
     }
 
     let getDiffChangeDetailStub;
-    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
@@ -105,8 +104,6 @@
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
-      getReviewedFilesStub = stubRestApi('getReviewedFiles').returns(
-          Promise.resolve([]));
 
       element = basicFixture.instantiate();
       element._changeNum = '42';
@@ -1157,10 +1154,11 @@
       const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
           .callsFake(() => Promise.resolve());
       const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .callsFake(() => Promise.resolve());
+          .returns(false);
 
       sinon.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
+      element._prefs = {manual_review: true};
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -1172,17 +1170,19 @@
         patchNum: 2,
         basePatchNum: 1,
       };
-      element._prefs = {manual_review: true};
       flush();
 
       assert.isFalse(saveReviewedStub.called);
       assert.isTrue(getReviewedStub.called);
 
+      const oldCount = getReviewedStub.callCount;
+
       element._prefs = {};
+      element._path = 'abcd';
       flush();
 
       assert.isTrue(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
+      assert.equal(getReviewedStub.callCount, oldCount);
     });
 
     test('file review status', () => {
@@ -1202,6 +1202,7 @@
         patchNum: 2,
         basePatchNum: 1,
       };
+      element._path = 'abcd';
       element._prefs = {};
       flush();
 
@@ -1673,25 +1674,6 @@
           [{value: '/foo'}, {value: '/bar'}]), 'show');
     });
 
-    test('_getReviewedStatus', () => {
-      const promises = [];
-      getReviewedFilesStub.returns(Promise.resolve(['path']));
-
-      promises.push(element._getReviewedStatus(true, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
-          .then(reviewed => assert.isTrue(reviewed)));
-
-      return Promise.all(promises);
-    });
-
     test('f open file dropdown', () => {
       assert.isFalse(element.$.dropdown.$.dropdown.opened);
       MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 0c6dd4d..0c36cd5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -57,7 +57,7 @@
         try {
           mayContinue = callback(e);
         } catch (exception) {
-          console.warn(`Plugin error handing event: ${exception}`);
+          this.reporting.error(exception);
         }
         if (mayContinue === false) {
           e.stopImmediatePropagation();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index 6ca484d..e530ac8 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -35,7 +35,7 @@
     <section>
       <span class="title"></span>
       <span class="value">
-        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
+        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
       </span>
     </section>
     <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index ab4c5a5..d7078ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -97,6 +97,13 @@
   _config?: ServerInfo;
 
   @property({type: Boolean, reflectToAttribute: true})
+  selectionChipStyle = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    observer: 'selectedChanged',
+  })
   selected = false;
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -126,6 +133,10 @@
     });
   }
 
+  selectedChanged(selected?: boolean) {
+    this.deselected = !selected;
+  }
+
   _isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index c5b66ce3..a642337 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -37,13 +37,13 @@
     :host::after {
       content: var(--account-label-suffix);
     }
-    :host([deselected]) {
+    :host([deselected][selection-chip-style]) {
       background-color: var(--background-color-primary);
       border: 1px solid var(--comment-separator-color);
       border-radius: 8px;
       color: var(--deemphasized-text-color);
     }
-    :host([selected]) {
+    :host([selected][selection-chip-style]) {
       background-color: var(--chip-selected-background-color);
       border: 1px solid var(--chip-selected-background-color);
       border-radius: 8px;
@@ -128,7 +128,7 @@
     class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
   >
     <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+      <gr-avatar account="[[account]]" imageSize="32"></gr-avatar>
     </template>
     <span class="text">
       <span class="name">[[_computeName(account, _config, firstName)]]</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 231fc36..130969e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -326,7 +326,9 @@
         return;
       }
     }
-    console.warn('received remove event for missing account', toRemove);
+    this.reporting.error(
+      new Error(`Received "remove" event for missing account: ${toRemove}`)
+    );
   }
 
   _getNativeInput(paperInput: PaperInputElementExt) {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index e30e995..80576a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -14,22 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-avatar_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
 
 @customElement('gr-avatar')
-export class GrAvatar extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object, observer: '_accountChanged'})
+export class GrAvatar extends GrLitElement {
+  @property({type: Object})
   account?: AccountInfo;
 
   @property({type: Number})
@@ -40,6 +34,27 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          border-radius: 50%;
+          background-size: cover;
+          background-color: var(
+            --avatar-background-color,
+            var(--gray-background)
+          );
+        }
+      `,
+    ];
+  }
+
+  render() {
+    this._updateAvatarURL();
+    return html``;
+  }
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -57,10 +72,6 @@
     return this.restApiService.getConfig();
   }
 
-  _accountChanged() {
-    this._updateAvatarURL();
-  }
-
   _updateAvatarURL() {
     if (!this._hasAvatars || !this.account) {
       this.hidden = true;
@@ -80,7 +91,7 @@
     );
   }
 
-  _buildAvatarURL(account: AccountInfo) {
+  _buildAvatarURL(account?: AccountInfo) {
     if (!account) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
deleted file mode 100644
index a1e51df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, var(--gray-background));
-    }
-  </style>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 12173d0..65e8e9f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -93,8 +93,12 @@
     resolveWeblinks?: GeneratedWebLink[],
     status?: ChangeStates
   ): boolean {
+    const isRevertCreatedOrSubmitted =
+      (status === ChangeStates.REVERT_SUBMITTED ||
+        status === ChangeStates.REVERT_CREATED) &&
+      revertedChange !== undefined;
     return (
-      revertedChange !== undefined ||
+      isRevertCreatedOrSubmitted ||
       !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
index 116dcaf..2ca2744b 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -87,7 +87,7 @@
   >
     <template
       is="dom-if"
-      if="[[!!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
       <a class="status-link"
          href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
         <div class="chip" aria-label$="Label: [[status]]">
@@ -100,7 +100,7 @@
         </div>
       </a>
     </template>
-    <template is="dom-if" if="[[!hasStatusLink(revertedChange)]]">
+    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
       <div class="chip" aria-label$="Label: [[status]]">
         [[_computeStatusString(status)]]
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index b4b4c19..d6ad030 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -706,7 +706,9 @@
     const index = this._indexOf(comment, this.comments);
     if (index === -1) {
       // This should never happen: comment belongs to another thread.
-      console.warn('Comment update for another comment thread.');
+      this.reporting.error(
+        new Error(`Comment update for another comment thread: ${comment}`)
+      );
       return;
     }
     this.set(['comments', index], comment);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 4ef25e2..83cd380 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -25,6 +25,7 @@
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../utils/common-util';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {Interaction} from '../../../constants/reporting';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -232,7 +233,7 @@
 
   _toggleCommitCollapsed() {
     this._commitCollapsed = !this._commitCollapsed;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
       toState: !this._commitCollapsed ? 'Show all' : 'Show less',
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 17b659f..7298af7 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -20,6 +20,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-formatted-text_html';
 import {CommentLinks} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
@@ -57,6 +58,8 @@
   @property({type: Boolean})
   noTrailingMargin = false;
 
+  private readonly reporting = appContext.reportingService;
+
   static get observers() {
     return ['_contentOrConfigChanged(content, config)'];
   }
@@ -304,7 +307,7 @@
         return ul;
       }
 
-      console.warn('Unrecognized type.');
+      this.reporting.error(new Error(`Unrecognized block type: ${block.type}`));
       return document.createElement('span');
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index d0986de..dac5962 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -75,7 +75,7 @@
     <template is="dom-if" if="[[_isShowing]]">
       <div class="top">
         <div class="avatar">
-          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+          <gr-avatar account="[[account]]" imageSize="56"></gr-avatar>
         </div>
         <div class="account">
           <h3 class="name heading-3">[[account.name]]</h3>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 3f75970..82c7118 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -42,7 +42,9 @@
   ): GrAnnotationActionsInterface {
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
-      console.warn('Overwriting an existing coverage provider.');
+      this.reporting.error(
+        new Error(`Overwriting cov provider: ${this.plugin.getPluginName()}`)
+      );
     }
     this.coverageProvider = coverageProvider;
     return this;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 15d4680..b9c4c32 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -80,7 +80,7 @@
    */
   private setEl(el?: GrChangeActionsElement) {
     if (!el) {
-      console.warn('changeActions() is not ready');
+      this.reporting.error(new Error(`changeActions() API is not ready`));
       return;
     }
     this.el = el;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 2135c30..46c7ad6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -22,6 +22,7 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {appContext} from '../../../services/app-context';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -30,6 +31,8 @@
 export class GrPluginActionContext {
   private popups: PopupPluginApi[] = [];
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     public readonly plugin: PluginApi,
     public readonly action: UIActionInfo,
@@ -108,7 +111,9 @@
   call(payload: RequestPayload, onSuccess: (result: unknown) => void) {
     if (!this.action.method) return;
     if (!this.action.__url) {
-      console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+      this.reporting.error(
+        new Error(`Unable to ${this.action.method} to ${this.action.__key}!`)
+      );
       return;
     }
     this.plugin
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index fe101c5..6fc8f1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -132,7 +132,7 @@
       try {
         url = new URL(url);
       } catch (e) {
-        console.warn(e);
+        this._getReporting().error(e);
         return false;
       }
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 35acdef..cfa6c04 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -79,9 +79,10 @@
     this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
-      console.warn(
-        'Plugin not being loaded from /plugins base path.',
-        'Unable to determine name.'
+      this.report.error(
+        new Error(
+          `Plugin not being loaded from /plugins base path. Unable to determine name.`
+        )
       );
       return this;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index db22ce5..31a709a 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -81,6 +81,8 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly reporting = appContext.reportingService;
+
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -209,7 +211,7 @@
         }
       })
       .catch(err => {
-        console.warn(err);
+        this.reporting.error(err);
         target.disabled = false;
         return;
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index 56ab8c6..b6583d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -88,7 +88,7 @@
   <p
     class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
   >
-    No votes.
+    No votes
   </p>
   <table>
     <template
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
index 4bdc1ab..0d44bc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
     }
@@ -30,6 +30,9 @@
       text-decoration: none;
       pointer-events: none;
     }
+    a {
+      color: var(--link-color);
+    }
   </style>
   <span id="output"></span>
 `;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 509a140..ac072f3 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -18,7 +18,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 export type EventValue = string | number | {error?: Error};
 
@@ -43,7 +48,7 @@
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
-  changeDisplayed(): void;
+  changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
   diffViewFullyLoaded(): void;
@@ -104,7 +109,10 @@
     object: string,
     method: string
   ): void;
-  reportInteraction(eventName: string, details?: EventDetails): void;
+  reportInteraction(
+    eventName: string | Interaction,
+    details?: EventDetails
+  ): void;
   /**
    * A draft interaction was started. Update the time-between-draft-actions
    * timer.
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 4e6ece2..0b93f07 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -21,7 +21,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 // Latency reporting constants.
 
@@ -508,11 +513,12 @@
     }
   }
 
-  changeDisplayed() {
+  changeDisplayed(eventDetails?: EventDetails) {
+    eventDetails = {...eventDetails, ...this._pageLoadDetails()};
     if (hasOwnProperty(this._baselines, Timing.STARTUP_CHANGE_DISPLAYED)) {
-      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, eventDetails);
     } else {
-      this.timeEnd(Timing.CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.CHANGE_DISPLAYED, eventDetails);
     }
   }
 
@@ -772,7 +778,7 @@
     );
   }
 
-  reportInteraction(eventName: string, details: EventDetails) {
+  reportInteraction(eventName: string | Interaction, details: EventDetails) {
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index c180439..24c27e0 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -17,7 +17,7 @@
 import {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution} from '../../constants/reporting';
+import {Execution, Interaction} from '../../constants/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -72,7 +72,10 @@
     log(`trackApi '${plugin}', ${object}, ${method}`);
   },
   reportExtension: () => {},
-  reportInteraction: (eventName: string, details?: EventDetails) => {
+  reportInteraction: (
+    eventName: string | Interaction,
+    details?: EventDetails
+  ) => {
     log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
   },
   reportLifeCycle: () => {},
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 414a80c..380d06c 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -174,13 +174,16 @@
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
+export function isOwner(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
 export function isReviewer(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   if (!change || !account) return false;
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
deleted file mode 100644
index 93073fa..0000000
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {
-  _testOnly_computeWipForPatchSets, computeAllPatchSets,
-  findEditParentPatchNum, findEditParentRevision,
-  getParentIndex, getRevisionByPatchNum,
-  isMergeParent,
-  sortRevisions,
-} from './patch-set-util.js';
-
-suite('gr-patch-set-util tests', () => {
-  test('getRevisionByPatchNum', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
-    assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
-  });
-
-  test('_computeWipForPatchSets', () => {
-    // Compute patch sets for a given timeline on a change. The initial WIP
-    // property of the change can be true or false. The map of tags by
-    // revision is keyed by patch set number. Each value is a list of change
-    // message tags in the order that they occurred in the timeline. These
-    // indicate actions that modify the WIP property of the change and/or
-    // create new patch sets.
-    //
-    // Returns the actual results with an assertWip method that can be used
-    // to compare against an expected value for a particular patch set.
-    const compute = (initialWip, tagsByRevision) => {
-      const change = {
-        messages: [],
-        work_in_progress: initialWip,
-      };
-      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
-      for (const rev of revs) {
-        for (const tag of tagsByRevision[rev]) {
-          change.messages.push({
-            tag,
-            _revision_number: rev,
-          });
-        }
-      }
-      let patchNums = revs.map(rev => { return {num: rev}; });
-      patchNums = _testOnly_computeWipForPatchSets(
-          change, patchNums);
-      const actualWipsByRevision = {};
-      for (const patchNum of patchNums) {
-        actualWipsByRevision[patchNum.num] = patchNum.wip;
-      }
-      const verifier = {
-        assertWip(revision, expectedWip) {
-          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
-          if (!patchNum) {
-            assert.fail('revision ' + revision + ' not found');
-          }
-          assert.equal(patchNum.wip, expectedWip,
-              'wip state for ' + revision + ' is ' +
-            patchNum.wip + '; expected ' + expectedWip);
-          return verifier;
-        },
-      };
-      return verifier;
-    };
-
-    compute(false, {1: ['upload']}).assertWip(1, false);
-    compute(true, {1: ['upload']}).assertWip(1, true);
-
-    const setWip = 'autogenerated:gerrit:setWorkInProgress';
-    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-    const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-    compute(false, {
-      1: ['upload', setWip],
-      2: ['upload'],
-      3: ['upload', clearWip],
-      4: ['upload', setWip],
-    }).assertWip(1, false) // Change was created with PS1 ready for review
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review after upload
-        .assertWip(4, false); // PS4 was uploaded ready for review
-
-    compute(false, {
-      1: [uploadInWip, null, 'addReviewer'],
-      2: ['upload'],
-      3: ['upload', clearWip, setWip],
-      4: ['upload'],
-      5: ['upload', clearWip],
-      6: [uploadInWip],
-    }).assertWip(1, true) // Change was created in WIP
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review
-        .assertWip(4, true) // PS4 was uploaded during WIP
-        .assertWip(5, false) // PS5 was marked ready for review
-        .assertWip(6, true); // PS6 was uploaded with WIP option
-  });
-
-  test('isMergeParent', () => {
-    assert.isFalse(isMergeParent(1));
-    assert.isFalse(isMergeParent(4321));
-    assert.isFalse(isMergeParent('52'));
-    assert.isFalse(isMergeParent('edit'));
-    assert.isFalse(isMergeParent('PARENT'));
-    assert.isFalse(isMergeParent(0));
-
-    assert.isTrue(isMergeParent(-23));
-    assert.isTrue(isMergeParent(-1));
-    assert.isTrue(isMergeParent('-42'));
-  });
-
-  test('findEditParentRevision', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 3}];
-    assert.deepEqual(findEditParentRevision(revisions), {_number: 3});
-  });
-
-  test('findEditParentPatchNum', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.equal(findEditParentPatchNum(revisions), -1);
-
-    revisions =
-        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-    assert.deepEqual(findEditParentPatchNum(revisions), 3);
-  });
-
-  test('sortRevisions', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 2},
-      {_number: 1},
-    ];
-    const sorted = [
-      {_number: 2},
-      {_number: 1},
-      {_number: 0},
-    ];
-
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    // Edit patchset should follow directly after its basePatchNum.
-    revisions.push({_number: 'edit', basePatchNum: 2});
-    sorted.unshift({_number: 'edit', basePatchNum: 2});
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    revisions[0].basePatchNum = 0;
-    const edit = sorted.shift();
-    edit.basePatchNum = 0;
-    // Edit patchset should be at index 2.
-    sorted.splice(2, 0, edit);
-    assert.deepEqual(sortRevisions(revisions), sorted);
-  });
-
-  test('getParentIndex', () => {
-    assert.equal(getParentIndex('-13'), 13);
-    assert.equal(getParentIndex(-4), 4);
-  });
-
-  test('computeAllPatchSets', () => {
-    const expected = [
-      {num: 4, desc: 'test', sha: 'rev4'},
-      {num: 3, desc: 'test', sha: 'rev3'},
-      {num: 2, desc: 'test', sha: 'rev2'},
-      {num: 1, desc: 'test', sha: 'rev1'},
-    ];
-    const patchNums = computeAllPatchSets({
-      revisions: {
-        rev3: {_number: 3, description: 'test', date: 3},
-        rev1: {_number: 1, description: 'test', date: 1},
-        rev4: {_number: 4, description: 'test', date: 4},
-        rev2: {_number: 2, description: 'test', date: 2},
-      },
-    });
-    assert.equal(patchNums.length, expected.length);
-    for (let i = 0; i < expected.length; i++) {
-      assert.deepEqual(patchNums[i], expected[i]);
-    }
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
new file mode 100644
index 0000000..a9d9549
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -0,0 +1,246 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createRevision,
+} from '../test/test-data-generators';
+import {
+  BasePatchSetNum,
+  ChangeInfo,
+  EditPatchSetNum,
+  PatchSetNum,
+  ReviewInputTag,
+} from '../types/common';
+import {
+  _testOnly_computeWipForPatchSets,
+  computeAllPatchSets,
+  findEditParentPatchNum,
+  findEditParentRevision,
+  getParentIndex,
+  getRevisionByPatchNum,
+  isMergeParent,
+  sortRevisions,
+} from './patch-set-util';
+
+suite('gr-patch-set-util tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 1 as PatchSetNum),
+      revisions[1]
+    );
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 2 as PatchSetNum),
+      revisions[2]
+    );
+    assert.equal(getRevisionByPatchNum(revisions, 3 as PatchSetNum), undefined);
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (
+      initialWip: boolean,
+      tagsByRevision: Map<
+        number | 'edit' | 'PARENT',
+        (ReviewInputTag | undefined)[]
+      >
+    ) => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      for (const rev of tagsByRevision.keys()) {
+        for (const tag of tagsByRevision.get(rev)!) {
+          change.messages!.push({
+            ...createChangeMessageInfo(),
+            tag,
+            _revision_number: rev as PatchSetNum,
+          });
+        }
+      }
+      const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
+        return {num: rev as PatchSetNum, desc: 'test', sha: `rev${rev}`};
+      });
+      const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
+      const verifier = {
+        assertWip(revision: number, expectedWip: boolean) {
+          const patchNum = patchNums.find(
+            patchNum => patchNum.num === (revision as PatchSetNum)
+          );
+          if (!patchNum) {
+            assert.fail(`revision ${revision} not found`);
+          }
+          assert.equal(
+            patchNum.wip,
+            expectedWip,
+            `wip state for ${revision} ` +
+              `is ${patchNum.wip}; expected ${expectedWip}`
+          );
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    const upload = 'upload' as ReviewInputTag;
+
+    compute(false, new Map([[1, [upload]]])).assertWip(1, false);
+    compute(true, new Map([[1, [upload]]])).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress' as ReviewInputTag;
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet' as ReviewInputTag;
+    const clearWip = 'autogenerated:gerrit:setReadyForReview' as ReviewInputTag;
+
+    compute(
+      false,
+      new Map([
+        [1, [upload, setWip]],
+        [2, [upload]],
+        [3, [upload, clearWip]],
+        [4, [upload, setWip]],
+      ])
+    )
+      .assertWip(1, false) // Change was created with PS1 ready for review
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review after upload
+      .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(
+      false,
+      new Map([
+        [1, [uploadInWip, undefined, 'addReviewer' as ReviewInputTag]],
+        [2, [upload]],
+        [3, [upload, clearWip, setWip]],
+        [4, [upload]],
+        [5, [upload, clearWip]],
+        [6, [uploadInWip]],
+      ])
+    )
+      .assertWip(1, true) // Change was created in WIP
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review
+      .assertWip(4, true) // PS4 was uploaded during WIP
+      .assertWip(5, false) // PS5 was marked ready for review
+      .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('isMergeParent', () => {
+    assert.isFalse(isMergeParent(1 as PatchSetNum));
+    assert.isFalse(isMergeParent(4321 as PatchSetNum));
+    assert.isFalse(isMergeParent('edit' as PatchSetNum));
+    assert.isFalse(isMergeParent('PARENT' as PatchSetNum));
+    assert.isFalse(isMergeParent(0 as PatchSetNum));
+
+    assert.isTrue(isMergeParent(-23 as PatchSetNum));
+    assert.isTrue(isMergeParent(-1 as PatchSetNum));
+  });
+
+  test('findEditParentRevision', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 3 as BasePatchSetNum,
+    });
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push(createRevision(3));
+    assert.deepEqual(findEditParentRevision(revisions), createRevision(3));
+  });
+
+  test('findEditParentPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.equal(findEditParentPatchNum(revisions), -1);
+
+    revisions.push(
+      {
+        ...createRevision(),
+        _number: EditPatchSetNum,
+        basePatchNum: 3 as BasePatchSetNum,
+      },
+      createRevision(3)
+    );
+    assert.deepEqual(findEditParentPatchNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const revisions = [createRevision(0), createRevision(2), createRevision(1)];
+    const sorted = [createRevision(2), createRevision(1), createRevision(0)];
+
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    sorted.unshift({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    revisions[0].basePatchNum = 0 as BasePatchSetNum;
+    const edit = sorted.shift()!;
+    edit.basePatchNum = 0 as BasePatchSetNum;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sortRevisions(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(getParentIndex(-4 as PatchSetNum), 4);
+  });
+
+  test('computeAllPatchSets', () => {
+    const expected = [
+      {num: 4 as PatchSetNum, desc: 'test', sha: 'rev4'},
+      {num: 3 as PatchSetNum, desc: 'test', sha: 'rev3'},
+      {num: 2 as PatchSetNum, desc: 'test', sha: 'rev2'},
+      {num: 1 as PatchSetNum, desc: 'test', sha: 'rev1'},
+    ];
+    const patchNums = computeAllPatchSets({
+      ...createChange(),
+      revisions: {
+        rev1: {...createRevision(1), description: 'test'},
+        rev2: {...createRevision(2), description: 'test'},
+        rev3: {...createRevision(3), description: 'test'},
+        rev4: {...createRevision(4), description: 'test'},
+      },
+    });
+    assert.equal(patchNums.length, expected.length);
+    for (let i = 0; i < expected.length; i++) {
+      assert.deepEqual(patchNums[i], expected[i]);
+    }
+  });
+});
diff --git a/proto/cache.proto b/proto/cache.proto
index 4efce6f..9d2ba06 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -475,7 +475,7 @@
   string name = 1;
   string description = 2;
   string applicability_expression = 3;
-  string blocking_expression = 4;
+  string submittability_expression = 4;
   string override_expression = 5;
   bool allow_override_in_child_projects = 6;
 }
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3d04592..434196f 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -6,7 +6,7 @@
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
-TESTCONTAINERS_VERSION = "1.15.1"
+TESTCONTAINERS_VERSION = "1.15.3"
 
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
@@ -187,21 +187,21 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    DOCKER_JAVA_VERS = "3.2.7"
+    DOCKER_JAVA_VERS = "3.2.8"
 
     maven_jar(
         name = "docker-java-api",
         artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
-        sha1 = "81408fc988c229ea11354fee9902c47842343f04",
+        sha1 = "4ac22a72d546a9f3523cd4b5fabffa77c4a6ec7c",
     )
 
     maven_jar(
         name = "docker-java-transport",
         artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
-        sha1 = "315903a129f530422747efc163dd255f0fa2555e",
+        sha1 = "c3b5598c67d0a5e2e780bf48f520da26b9915eab",
     )
 
-    # https://github.com/docker-java/docker-java/blob/3.2.7/pom.xml#L61
+    # https://github.com/docker-java/docker-java/blob/3.2.8/pom.xml#L61
     # <=> DOCKER_JAVA_VERS
     maven_jar(
         name = "jackson-annotations",
@@ -212,7 +212,7 @@
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "91e6dfab8f141f77c6a0dd147a94bd186993a22c",
+        sha1 = "95c6cfde71c2209f0c29cb14e432471e0b111880",
     )
 
     maven_jar(