Merge "Remove PatchListCacheIT"
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 8f36cfb..feafe59 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -31,6 +31,12 @@
 * `cancellation/receive_timeout_count`: Number of requests that are cancelled
   because link:config.html#receive.timeout[receive.timout] is exceeded
 
+[[performance]]
+=== Performance
+
+* `performance/operations`: Latency of performing operations
+* `performance/operations_count`: Number of performed operations
+
 === Pushes
 
 * `receivecommits/changes`: histogram of number of changes processed
@@ -89,6 +95,7 @@
 
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a change.
+* `change/post_review/draft_handling`: Total number of draft handling option (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) selected by users while posting a review.
 
 === Comments
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 2ebe6bd..444c9ee 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -427,6 +427,11 @@
 +
 True if the change has unresolved comments.
 
+has:attention::
++
+True if the change has attention by the current user.
+
+
 [[is]]
 is:assigned::
 +
@@ -442,6 +447,10 @@
 +
 True if the change does not have an assignee.
 
+is:attention::
++
+True if the change has attention by the current user.
+
 is:watched::
 +
 True if this change matches one of the current user's watch filters,
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
index 2324330..5d63476 100644
--- a/java/com/google/gerrit/entities/Address.java
+++ b/java/com/google/gerrit/entities/Address.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.gerrit.common.Nullable;
 
 /** Represents an address (name + email) in an email message. */
@@ -66,8 +67,9 @@
 
   public abstract String email();
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return email().hashCode();
   }
 
diff --git a/java/com/google/gerrit/entities/GroupReference.java b/java/com/google/gerrit/entities/GroupReference.java
index 208ba0f..125153e 100644
--- a/java/com/google/gerrit/entities/GroupReference.java
+++ b/java/com/google/gerrit/entities/GroupReference.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.gerrit.common.Nullable;
 
 /** Describes a group within a projects {@link AccessSection}s. */
@@ -78,8 +79,9 @@
     return "?";
   }
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return uuid(this).hashCode();
   }
 
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
index 17da81f..5c0a3db 100644
--- a/java/com/google/gerrit/entities/NotifyConfig.java
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
@@ -106,8 +107,9 @@
     return getName().compareTo(o.getName());
   }
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return getName().hashCode();
   }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 3c39ea1..91659f8 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -768,13 +768,12 @@
             }
           } else {
             res.reset();
-            traceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+            TraceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
 
             if (status.isPresent()) {
-              responseBytes = reply(req, res, e, status.get(), getUserMessages(traceContext, e));
+              responseBytes = reply(req, res, e, status.get(), getUserMessages(e));
             } else {
-              responseBytes =
-                  replyInternalServerError(req, res, e, getUserMessages(traceContext, e));
+              responseBytes = replyInternalServerError(req, res, e, getUserMessages(e));
             }
           }
         }
@@ -984,7 +983,7 @@
       throws Exception {
     RetryableAction<T> retryableAction = globals.retryHelper.action(actionType, caller, action);
     AtomicReference<Optional<String>> traceId = new AtomicReference<>(Optional.empty());
-    if (!traceContext.isTracing()) {
+    if (!TraceContext.isTracing()) {
       // enable automatic retry with tracing in case of non-recoverable failure
       retryableAction
           .retryWithTrace(t -> !(t instanceof RestApiException))
@@ -1876,9 +1875,9 @@
         .findFirst();
   }
 
-  private ImmutableList<String> getUserMessages(TraceContext traceContext, Throwable err) {
+  private ImmutableList<String> getUserMessages(Throwable err) {
     return globals.exceptionHooks.stream()
-        .flatMap(h -> h.getUserMessages(err, traceContext.getTraceId().orElse(null)).stream())
+        .flatMap(h -> h.getUserMessages(err, TraceContext.getTraceId().orElse(null)).stream())
         .collect(toImmutableList());
   }
 
diff --git a/java/com/google/gerrit/pgm/init/InitAuth.java b/java/com/google/gerrit/pgm/init/InitAuth.java
index c15cff3..948ec49 100644
--- a/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.mail.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -42,11 +43,13 @@
   private final Section ldap;
   private final Section receive;
   private final InitFlags flags;
+  private final SitePaths site;
 
   @Inject
-  InitAuth(InitFlags flags, ConsoleUI ui, Section.Factory sections) {
+  InitAuth(InitFlags flags, ConsoleUI ui, final SitePaths site, Section.Factory sections) {
     this.flags = flags;
     this.ui = ui;
+    this.site = site;
     this.auth = sections.get("auth", null);
     this.ldap = sections.get("ldap", null);
     this.receive = sections.get(RECEIVE, null);
@@ -62,6 +65,10 @@
     }
 
     initSignedPush();
+
+    if (site.isNew) {
+      initUserNameCaseSensitivity();
+    }
   }
 
   private void initAuthType() {
@@ -156,4 +163,9 @@
     boolean enable = ui.yesno(def, "Enable signed push support");
     receive.set("enableSignedPush", Boolean.toString(enable));
   }
+
+  private void initUserNameCaseSensitivity() {
+    boolean enableCaseInsensitivity = ui.yesno(true, "Use case insensitive usernames");
+    auth.set("userNameCaseInsensitive", Boolean.toString(enableCaseInsensitivity));
+  }
 }
diff --git a/java/com/google/gerrit/server/CancellationMetrics.java b/java/com/google/gerrit/server/CancellationMetrics.java
index 2d0b878..487a748 100644
--- a/java/com/google/gerrit/server/CancellationMetrics.java
+++ b/java/com/google/gerrit/server/CancellationMetrics.java
@@ -14,11 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Counter3;
@@ -87,17 +82,13 @@
 
   public void countAdvisoryDeadline(RequestInfo requestInfo, String deadlineId) {
     advisoryDeadlineCount.increment(
-        requestInfo.requestType(),
-        requestInfo.requestUri().map(CancellationMetrics::redactRequestUri).orElse(""),
-        deadlineId);
+        requestInfo.requestType(), requestInfo.redactedRequestUri().orElse(""), deadlineId);
   }
 
   public void countCancelledRequest(
       RequestInfo requestInfo, RequestStateProvider.Reason cancellationReason) {
     cancelledRequestsCount.increment(
-        requestInfo.requestType(),
-        requestInfo.requestUri().map(CancellationMetrics::redactRequestUri).orElse(""),
-        cancellationReason);
+        requestInfo.requestType(), requestInfo.redactedRequestUri().orElse(""), cancellationReason);
   }
 
   public void countCancelledRequest(
@@ -105,7 +96,7 @@
       String requestUri,
       RequestStateProvider.Reason cancellationReason) {
     cancelledRequestsCount.increment(
-        requestType.name(), CancellationMetrics.redactRequestUri(requestUri), cancellationReason);
+        requestType.name(), RequestInfo.redactRequestUri(requestUri), cancellationReason);
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
@@ -123,58 +114,4 @@
   public void countForcefulReceiveTimeout() {
     receiveTimeoutCount.increment("forceful");
   }
-
-  /**
-   * Redacts resource IDs from the given request URI.
-   *
-   * <p>resource IDs in the request URI are replaced with '*'.
-   *
-   * @param requestUri a REST URI that has path segments that alternate between view name and
-   *     resource IDs (e.g. "/<view>", "/<view>/<id>", "/<view>/<id>/<view>",
-   *     "/<view>/<id>/<view>/<id>", "/<view>/<id>/<view>/<id>/<view>" etc.), must be given without
-   *     the '/a' prefix
-   * @return the redacted request URI
-   */
-  @VisibleForTesting
-  static String redactRequestUri(String requestUri) {
-    requireNonNull(requestUri, "requestUri");
-    checkState(
-        !requestUri.startsWith("/a/"), "request URI must not start with '/a/': %s", requestUri);
-
-    StringBuilder redactedRequestUri = new StringBuilder();
-
-    boolean hasLeadingSlash = false;
-    boolean hasTrailingSlash = false;
-    if (requestUri.startsWith("/")) {
-      hasLeadingSlash = true;
-      requestUri = requestUri.substring(1);
-    }
-    if (requestUri.endsWith("/")) {
-      hasTrailingSlash = true;
-      requestUri = requestUri.substring(0, requestUri.length() - 1);
-    }
-
-    boolean idPathSegment = false;
-    for (String pathSegment : Splitter.on('/').split(requestUri)) {
-      if (!idPathSegment) {
-        redactedRequestUri.append("/" + pathSegment);
-        idPathSegment = true;
-      } else {
-        redactedRequestUri.append("/");
-        if (!pathSegment.isEmpty()) {
-          redactedRequestUri.append("*");
-        }
-        idPathSegment = false;
-      }
-    }
-
-    if (!hasLeadingSlash) {
-      redactedRequestUri.deleteCharAt(0);
-    }
-    if (hasTrailingSlash) {
-      redactedRequestUri.append('/');
-    }
-
-    return redactedRequestUri.toString();
-  }
 }
diff --git a/java/com/google/gerrit/server/DeadlineChecker.java b/java/com/google/gerrit/server/DeadlineChecker.java
index 5662e50..f41b1e3 100644
--- a/java/com/google/gerrit/server/DeadlineChecker.java
+++ b/java/com/google/gerrit/server/DeadlineChecker.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.HashSet;
@@ -134,7 +135,7 @@
     this(
         serverConfig,
         cancellationsMetrics,
-        System.nanoTime(),
+        TimeUtil.nowNanos(),
         requestInfo,
         clientProvidedTimeoutValue);
   }
@@ -236,7 +237,7 @@
 
   @Override
   public void checkIfCancelled(OnCancelled onCancelled) {
-    long now = System.nanoTime();
+    long now = TimeUtil.nowNanos();
 
     Set<String> exceededAdvisoryDeadlines = new HashSet<>();
     advisoryDeadlines
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
new file mode 100644
index 0000000..fa6e22c
--- /dev/null
+++ b/java/com/google/gerrit/server/PerformanceMetrics.java
@@ -0,0 +1,108 @@
+// 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;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.TimeUnit;
+
+/** Performance logger that records the execution times as a metric. */
+@Singleton
+public class PerformanceMetrics implements PerformanceLogger {
+  private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
+  private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
+
+  public final Timer3<String, String, String> operationsLatency;
+  public final Counter3<String, String, String> operationsCounter;
+
+  @Inject
+  PerformanceMetrics(MetricMaker metricMaker) {
+    Field<String> operationNameField =
+        Field.ofString(
+                "operation_name",
+                (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
+            .build();
+    Field<String> changeIdentifierField =
+        Field.ofString("change_identifier", (metadataBuilder, fieldValue) -> {}).build();
+    Field<String> traceIdField =
+        Field.ofString("trace_id", (metadataBuilder, fieldValue) -> {}).build();
+    Field<String> requestField =
+        Field.ofString("request", (metadataBuilder, fieldValue) -> {}).build();
+
+    this.operationsLatency =
+        metricMaker.newTimer(
+            OPERATION_LATENCY_METRIC_NAME,
+            new Description("Latency of performing operations")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS),
+            operationNameField,
+            changeIdentifierField,
+            traceIdField);
+    this.operationsCounter =
+        metricMaker.newCounter(
+            OPERATION_COUNT_METRIC_NAME,
+            new Description("Number of performed operations").setRate(),
+            operationNameField,
+            traceIdField,
+            requestField);
+  }
+
+  @Override
+  public void log(String operation, long durationMs) {
+    log(operation, durationMs, /* metadata= */ null);
+  }
+
+  @Override
+  public void log(String operation, long durationMs, @Nullable Metadata metadata) {
+    if (OPERATION_LATENCY_METRIC_NAME.equals(operation)) {
+      // Recording the timer metric below triggers writing a performance log entry. If we are called
+      // for this performance log entry we must abort to avoid an endless loop.
+      // In practice this should not happen since PerformanceLoggers are only called on close() of
+      // the PerformanceLogContext, and hence the performance log that gets written by the metric
+      // below gets ignored.
+      return;
+    }
+
+    String traceId = TraceContext.getTraceId().orElse("");
+
+    operationsLatency.record(
+        operation, formatChangeIdentifier(metadata), traceId, durationMs, TimeUnit.MILLISECONDS);
+
+    String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
+    operationsCounter.increment(operation, traceId, requestTag);
+  }
+
+  private String formatChangeIdentifier(@Nullable Metadata metadata) {
+    if (metadata == null
+        || (!metadata.projectName().isPresent() && !metadata.changeId().isPresent())) {
+      return "";
+    }
+
+    StringBuilder sb = new StringBuilder();
+    sb.append(metadata.projectName().orElse("n/a"));
+    sb.append('~');
+    sb.append(metadata.changeId().map(String::valueOf).orElse("n/a"));
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
index 053b3ac..791e228 100644
--- a/java/com/google/gerrit/server/RequestInfo.java
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -14,7 +14,12 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.base.Splitter;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.logging.TraceContext;
@@ -55,6 +60,16 @@
    */
   public abstract Optional<String> requestUri();
 
+  /**
+   * Redacted request URI.
+   *
+   * <p>Request URI where resource IDs are replaced by '*'.
+   */
+  @Memoized
+  public Optional<String> redactedRequestUri() {
+    return requestUri().map(RequestInfo::redactRequestUri);
+  }
+
   /** The user that has sent the request. */
   public abstract CurrentUser callingUser();
 
@@ -68,6 +83,67 @@
    */
   public abstract Optional<Project.NameKey> project();
 
+  @Memoized
+  public String formatForLogging() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(requestType());
+    redactedRequestUri().ifPresent(redactedRequestUri -> sb.append(' ').append(redactedRequestUri));
+    return sb.toString();
+  }
+
+  /**
+   * Redacts resource IDs from the given request URI.
+   *
+   * <p>resource IDs in the request URI are replaced with '*'.
+   *
+   * @param requestUri a REST URI that has path segments that alternate between view name and
+   *     resource IDs (e.g. "/<view>", "/<view>/<id>", "/<view>/<id>/<view>",
+   *     "/<view>/<id>/<view>/<id>", "/<view>/<id>/<view>/<id>/<view>" etc.), must be given without
+   *     the '/a' prefix
+   * @return the redacted request URI
+   */
+  static String redactRequestUri(String requestUri) {
+    requireNonNull(requestUri, "requestUri");
+    checkState(
+        !requestUri.startsWith("/a/"), "request URI must not start with '/a/': %s", requestUri);
+
+    StringBuilder redactedRequestUri = new StringBuilder();
+
+    boolean hasLeadingSlash = false;
+    boolean hasTrailingSlash = false;
+    if (requestUri.startsWith("/")) {
+      hasLeadingSlash = true;
+      requestUri = requestUri.substring(1);
+    }
+    if (requestUri.endsWith("/")) {
+      hasTrailingSlash = true;
+      requestUri = requestUri.substring(0, requestUri.length() - 1);
+    }
+
+    boolean idPathSegment = false;
+    for (String pathSegment : Splitter.on('/').split(requestUri)) {
+      if (!idPathSegment) {
+        redactedRequestUri.append("/" + pathSegment);
+        idPathSegment = true;
+      } else {
+        redactedRequestUri.append("/");
+        if (!pathSegment.isEmpty()) {
+          redactedRequestUri.append("*");
+        }
+        idPathSegment = false;
+      }
+    }
+
+    if (!hasLeadingSlash) {
+      redactedRequestUri.deleteCharAt(0);
+    }
+    if (hasTrailingSlash) {
+      redactedRequestUri.append('/');
+    }
+
+    return redactedRequestUri.toString();
+  }
+
   public static RequestInfo.Builder builder(
       RequestType requestType, CurrentUser callingUser, TraceContext traceContext) {
     return builder().requestType(requestType).callingUser(callingUser).traceContext(traceContext);
diff --git a/java/com/google/gerrit/server/TraceRequestListener.java b/java/com/google/gerrit/server/TraceRequestListener.java
index 7136e47..6cc0982 100644
--- a/java/com/google/gerrit/server/TraceRequestListener.java
+++ b/java/com/google/gerrit/server/TraceRequestListener.java
@@ -28,6 +28,9 @@
  */
 @Singleton
 public class TraceRequestListener implements RequestListener {
+  public static String TAG_REQUEST = "request";
+
+  private static String TAG_PROJECT = "project";
   private static String SECTION_TRACING = "tracing";
 
   private final ImmutableList<RequestConfig> traceConfigs;
@@ -39,7 +42,8 @@
 
   @Override
   public void onRequest(RequestInfo requestInfo) {
-    requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag("project", p));
+    requestInfo.traceContext().addTag(TAG_REQUEST, requestInfo.formatForLogging());
+    requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag(TAG_PROJECT, p));
     traceConfigs.stream()
         .filter(traceConfig -> traceConfig.matches(requestInfo))
         .forEach(
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index bbee1b2..30f4094 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
@@ -190,6 +191,7 @@
      * notes branch.
      */
     @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
+    @Memoized
     public ObjectId sha1() {
       String keyString = isCaseInsensitive() ? get().toLowerCase(Locale.US) : get();
       return ObjectId.fromRaw(Hashing.sha1().hashString(keyString, UTF_8).asBytes());
@@ -225,7 +227,8 @@
     }
 
     @Override
-    public final int hashCode() {
+    @Memoized
+    public int hashCode() {
       return Objects.hash(sha1());
     }
 
@@ -301,8 +304,9 @@
         && Objects.equals(password(), o.password());
   }
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return Objects.hash(key(), accountId(), isCaseInsensitive(), email(), password());
   }
 
@@ -320,7 +324,8 @@
    * </pre>
    */
   @Override
-  public final String toString() {
+  @Memoized
+  public String toString() {
     Config c = new Config();
     writeToConfig(c);
     return c.toText();
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index ef1c0ae..35b16b4 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -85,6 +85,7 @@
 import com.google.gerrit.server.ExceptionHookImpl;
 import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PerformanceMetrics;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
 import com.google.gerrit.server.account.AccountCacheImpl;
@@ -431,6 +432,7 @@
     DynamicSet.setOf(binder(), SubmitRule.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
+    DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 42fc916..f7f58fc 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -627,7 +627,7 @@
   ReceiveCommitsResult processCommands(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) throws StorageException {
     checkState(!used, "Tried to re-use a ReceiveCommits objects that is single-use only");
-    long start = System.nanoTime();
+    long start = TimeUtil.nowNanos();
     parsePushOptions();
     String clientProvidedDeadlineValue =
         Iterables.getLast(pushOptions.get("deadline"), /* defaultValue= */ null);
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 02ec5ea..bfe1ee1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -342,6 +342,11 @@
       integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
           .buildRepeatable(ChangeField::getAttentionSetUserIds);
 
+  /** Number of changes that contain attention set. */
+  public static final FieldDef<ChangeData, Integer> ATTENTION_SET_USERS_COUNT =
+      intRange(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT)
+          .build(cd -> additionsOnly(cd.attentionSet()).size());
+
   /**
    * The full attention set data including timestamp, reason and possible future fields.
    *
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index a758377..30ab6e6a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -173,9 +173,14 @@
       new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
 
   /** Added new field {@link ChangeField#CHERRY_PICK}. */
+  @Deprecated
   static final Schema<ChangeData> V69 =
       new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
 
+  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
+  static final Schema<ChangeData> V70 =
+      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 681dfbc..e333824 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -268,15 +268,19 @@
     return this;
   }
 
-  public boolean isTracing() {
+  public static boolean isTracing() {
     return LoggingContext.getInstance().isLoggingForced();
   }
 
-  public Optional<String> getTraceId() {
+  public static Optional<String> getTraceId() {
     return LoggingContext.getInstance().getTagsAsMap().get(RequestId.Type.TRACE_ID.name()).stream()
         .findFirst();
   }
 
+  public static Optional<String> getTag(String tagName) {
+    return LoggingContext.getInstance().getTagsAsMap().get(tagName).stream().findFirst();
+  }
+
   public TraceContext enableAclLogging() {
     if (stopAclLoggingOnClose) {
       return this;
@@ -286,11 +290,7 @@
     return this;
   }
 
-  public boolean isAclLoggingEnabled() {
-    return LoggingContext.getInstance().isAclLogging();
-  }
-
-  public ImmutableList<String> getAclLogRecords() {
+  public static ImmutableList<String> getAclLogRecords() {
     return LoggingContext.getInstance().getAclLogRecords();
   }
 
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index d800782..e64f8b6 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -126,22 +127,22 @@
 
     public abstract List<String> patterns();
 
-    public abstract int cachedHashCode();
-
     static EntryKey create(String refName, List<AccessSection> sections) {
-      int hc = refName.hashCode();
       List<String> patterns = new ArrayList<>(sections.size());
       for (AccessSection s : sections) {
-        String n = s.getName();
-        patterns.add(n);
-        hc = hc * 31 + n.hashCode();
+        patterns.add(s.getName());
       }
-      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns), hc);
+      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns));
     }
 
+    @Memoized
     @Override
-    public final int hashCode() {
-      return cachedHashCode();
+    public int hashCode() {
+      int hc = ref().hashCode();
+      for (String n : patterns()) {
+        hc = hc * 31 + n.hashCode();
+      }
+      return hc;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 8525eb4..f1fe520 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -142,6 +142,7 @@
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
   public static final String FIELD_ATTENTION_SET_USERS = "attentionusers";
+  public static final String FIELD_ATTENTION_SET_USERS_COUNT = "attentionuserscount";
   public static final String FIELD_ATTENTION_SET_FULL = "attentionfull";
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
@@ -614,6 +615,14 @@
       return ChangePredicates.editBy(self());
     }
 
+    if ("attention".equalsIgnoreCase(value)) {
+      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
+        throw new QueryParseException(
+            "'has:attention' operator is not supported by change index version");
+      }
+      return new IsAttentionPredicate();
+    }
+
     if ("unresolved".equalsIgnoreCase(value)) {
       return new IsUnresolvedPredicate();
     }
@@ -687,6 +696,14 @@
           "'is:private' operator is not supported by change index version");
     }
 
+    if ("attention".equalsIgnoreCase(value)) {
+      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
+        throw new QueryParseException(
+            "'is:attention' operator is not supported by change index version");
+      }
+      return new IsAttentionPredicate();
+    }
+
     if ("assigned".equalsIgnoreCase(value)) {
       return Predicate.not(ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE)));
     }
diff --git a/java/com/google/gerrit/server/query/change/IsAttentionPredicate.java b/java/com/google/gerrit/server/query/change/IsAttentionPredicate.java
new file mode 100644
index 0000000..d20d64a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/IsAttentionPredicate.java
@@ -0,0 +1,33 @@
+// 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.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class IsAttentionPredicate extends IntegerRangeChangePredicate {
+  public IsAttentionPredicate() throws QueryParseException {
+    this(">0");
+  }
+
+  public IsAttentionPredicate(String value) throws QueryParseException {
+    super(ChangeField.ATTENTION_SET_USERS_COUNT, value);
+  }
+
+  @Override
+  protected Integer getValueInt(ChangeData changeData) {
+    return ChangeField.ATTENTION_SET_USERS_COUNT.get(changeData);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 6d3e222..f70379b 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -33,6 +33,7 @@
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
         "//lib/commons:lang",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4dbb6ee..94dc21f 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -80,6 +80,10 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -151,6 +155,25 @@
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @Singleton
+  private static class Metrics {
+    final Counter1<String> draftHandling;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      draftHandling =
+          metricMaker.newCounter(
+              "change/post_review/draft_handling",
+              new Description(
+                      "Total number of draft handling option "
+                          + "(KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) "
+                          + "selected by users while posting a review.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("type", Metadata.Builder::eventType).build());
+    }
+  }
+
   private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
@@ -170,6 +193,7 @@
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
+  private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
   private final NotifyResolver notifyResolver;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
@@ -196,6 +220,7 @@
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
+      Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
       NotifyResolver notifyResolver,
       @GerritServerConfig Config gerritConfig,
@@ -218,6 +243,7 @@
     this.email = email;
     this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
+    this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
     this.notifyResolver = notifyResolver;
     this.workInProgressOpFactory = workInProgressOpFactory;
@@ -252,6 +278,7 @@
 
     logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled"));
 
+    metrics.draftHandling.increment(input.drafts == null ? "N/A" : input.drafts.name());
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     logger.atFine().log("draft handling = %s", input.drafts);
 
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
index 1d550f1..0634081 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -19,6 +19,7 @@
 import static java.util.stream.Collectors.toMap;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -266,8 +267,9 @@
       return psId().changeId();
     }
 
+    @Memoized
     @Override
-    public final int hashCode() {
+    public int hashCode() {
       return Objects.hash(patchSet().id(), commit());
     }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 37616cd..5c2f932 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -95,7 +95,6 @@
       } catch (AuthException e) {
         return Response.ok(
             createInfo(
-                traceContext,
                 HttpServletResponse.SC_FORBIDDEN,
                 String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
@@ -126,7 +125,6 @@
         } catch (AuthException e) {
           return Response.ok(
               createInfo(
-                  traceContext,
                   HttpServletResponse.SC_FORBIDDEN,
                   String.format(
                       "user %s lacks permission %s for %s in project %s",
@@ -141,15 +139,15 @@
           }
         }
       }
-      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
+      return Response.ok(createInfo(HttpServletResponse.SC_OK, message));
     }
   }
 
-  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+  private AccessCheckInfo createInfo(int statusCode, String message) {
     AccessCheckInfo info = new AccessCheckInfo();
     info.status = statusCode;
     info.message = message;
-    info.debugLogs = traceContext.getAclLogRecords();
+    info.debugLogs = TraceContext.getAclLogRecords();
     if (info.debugLogs.isEmpty()) {
       info.debugLogs =
           ImmutableList.of("Found no rules that apply, so defaulting to no permission");
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 2249b0e..94becc7 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -479,7 +479,7 @@
                   }
 
                   String cause = formatCause(t);
-                  if (!traceContext.isTracing()) {
+                  if (!TraceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
                     logger.atWarning().withCause(t).log(
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 639d0a6..54ef305 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.util.git.DelegateSystemReader;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -35,6 +36,10 @@
     return currentMillisSupplier.getAsLong();
   }
 
+  public static long nowNanos() {
+    return TimeUnit.NANOSECONDS.convert(TimeUtil.nowMs(), TimeUnit.MILLISECONDS);
+  }
+
   public static Instant now() {
     return Instant.ofEpochMilli(nowMs());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 85a7b29..875ce97 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -156,15 +156,15 @@
   @UseClockStep
   @Test
   public void addedRobotCommentsAreLinkedToChangeMessages() throws Exception {
-    TestTimeUtil.resetWithClockStep(0, TimeUnit.SECONDS);
-    createChange();
-    /* Advancing the time after creating the change so that the first robot comment is not in the same timestamp as with the change creation */
+    // Advancing the time after creating the change so that the first robot comment is not in the
+    // same timestamp as with the change creation.
     TestTimeUtil.incrementClock(10, TimeUnit.SECONDS);
 
     RobotCommentInput c1 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c3 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
-    /* Give the robot comments identifiable names for testing */
+
+    // Give the robot comments identifiable names for testing
     c1.message = "robot comment 1";
     c2.message = "robot comment 2";
     c3.message = "robot comment 3";
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 88d0937..943d990 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -21,6 +21,7 @@
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -116,6 +117,28 @@
     assertDeleteRef(OK);
   }
 
+  @Test
+  public void directPushSendsEmail() throws Exception {
+    // create a change
+    PushOneCommit push1 =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+    PushOneCommit.Result r = push1.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add reviewer to receive notifications
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    sender.clear();
+
+    // direct submit the change
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
+    r1.assertOkStatus();
+
+    // email received
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("has submitted this change");
+  }
+
   private void assertDeleteRef(RemoteRefUpdate.Status expectedStatus) throws Exception {
     BranchInput in = new BranchInput();
     in.ref = "refs/heads/test";
diff --git a/javatests/com/google/gerrit/server/CancellationMetricsTest.java b/javatests/com/google/gerrit/server/RequestInfoTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/CancellationMetricsTest.java
rename to javatests/com/google/gerrit/server/RequestInfoTest.java
index 2343c71..fafe856 100644
--- a/javatests/com/google/gerrit/server/CancellationMetricsTest.java
+++ b/javatests/com/google/gerrit/server/RequestInfoTest.java
@@ -18,7 +18,7 @@
 
 import org.junit.Test;
 
-public class CancellationMetricsTest {
+public class RequestInfoTest {
   @Test
   public void redactRequestUri() throws Exception {
     // test with valid request URIs
@@ -52,6 +52,6 @@
   }
 
   public static String redact(String uri) {
-    return CancellationMetrics.redactRequestUri(uri);
+    return RequestInfo.redactRequestUri(uri);
   }
 }
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index ed4325d..fefa066 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -34,6 +34,7 @@
 import com.google.inject.Injector;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
 import org.junit.Before;
@@ -110,9 +111,10 @@
 
   @Test
   public void
-      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers()
+          throws Exception {
     // Remove test performance logger so that there are no registered performance loggers.
-    performanceLoggerRegistrationHandle.remove();
+    removeAllPerformanceLoggers();
 
     assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
     assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
@@ -277,9 +279,10 @@
 
   @Test
   public void
-      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
-    // Remove test performance logger so that there are no registered performance loggers.
-    performanceLoggerRegistrationHandle.remove();
+      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers()
+          throws Exception {
+    // Remove all performance loggers so that there are no registered performance loggers.
+    removeAllPerformanceLoggers();
 
     assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
     assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
@@ -369,6 +372,12 @@
     }
   }
 
+  private void removeAllPerformanceLoggers() throws Exception {
+    java.lang.reflect.Field itemsField = DynamicSet.class.getDeclaredField("items");
+    itemsField.setAccessible(true);
+    ((CopyOnWriteArrayList<?>) itemsField.get(performanceLoggers)).clear();
+  }
+
   @AutoValue
   abstract static class PerformanceLogEntry {
     static PerformanceLogEntry create(String operation, Metadata metadata) {
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 92cbc41..c8949e6 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3432,6 +3432,7 @@
   @Test
   public void attentionSetIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -3439,8 +3440,18 @@
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
 
+    assertQuery("is:attention", change1);
+    assertQuery("-is:attention", change2);
+    assertQuery("has:attention", change1);
+    assertQuery("-has:attention", change2);
     assertQuery("attention:" + user.getUserName().get(), change1);
     assertQuery("-attention:" + userId.toString(), change2);
+
+    gApi.changes()
+        .id(change1.getChangeId())
+        .attention(userId.toString())
+        .remove(new AttentionSetInput("removed again"));
+    assertQuery("-is:attention", change1, change2);
   }
 
   @Test
diff --git a/package.json b/package.json
index 6ad3ab4..c3dfad0 100644
--- a/package.json
+++ b/package.json
@@ -33,8 +33,8 @@
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
-    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
-    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles",
+    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
     "postinstall": "(git apply --reverse --ignore-whitespace twinkie.patch || true) && git apply --ignore-whitespace twinkie.patch",
     "polytest": "npm run safe_bazelisk test //polygerrit-ui/app:validate_polymer_templates",
     "polytest:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
diff --git a/plugins/delete-project b/plugins/delete-project
index 7dce6f7..6202327 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 7dce6f70611cd8dbf1d38628698155258ee8ef82
+Subproject commit 6202327fe2ac6a86c838e624468ab30ee31a4bee
diff --git a/plugins/tsconfig-plugins-base.json b/plugins/tsconfig-plugins-base.json
index b580549..97eae67 100644
--- a/plugins/tsconfig-plugins-base.json
+++ b/plugins/tsconfig-plugins-base.json
@@ -34,6 +34,15 @@
     "incremental": true,
     "experimentalDecorators": true,
 
-    "allowUmdGlobalAccess": true
+    "allowUmdGlobalAccess": true,
+
+    "typeRoots": [
+      /* typeRoots for Bazel */
+      "../external/ui_dev_npm/node_modules/@types",
+      "../external/plugins_npm/node_modules/@types",
+      /* typeRoots for IDE */
+      "../polygerrit-ui/node_modules/@types",
+      "../plugins/node_modules/@types"
+    ]
   },
 }
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 7bca96d..62d1d92 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,5 +1,6 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:js.bzl", "karma_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -33,8 +34,6 @@
     ],
 )
 
-# Define a karma+plugins binary to run karma-mocha tests.
-# Can be reused multiple time, if there are multiple karma test rules
 sh_binary(
     name = "karma_bin",
     srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
@@ -49,26 +48,8 @@
     ],
 )
 
-# Run all tests in one.
-# TODO(dmfilippov): allow parallel tests for karma - either on the bazel level
-# or on the karma level. For now single sh_test is enough.
-sh_test(
+karma_test(
     name = "karma_test",
-    size = "enormous",
     srcs = ["karma_test.sh"],
-    args = [
-        "$(location :karma_bin)",
-        "$(location karma.conf.js)",
-    ],
-    data = [
-        "karma.conf.js",
-        ":karma_bin",
-        "//polygerrit-ui/app:test-srcs-fg",
-    ],
-    # Should not run sandboxed.
-    tags = [
-        "karma",
-        "local",
-        "manual",
-    ],
+    data = ["//polygerrit-ui/app:test-srcs-fg"],
 )
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 4a186c1..613efd6 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -70,7 +70,6 @@
         [
             "**/*.js",
             "**/*.ts",
-            "test/@types/*.d.ts",
         ],
         exclude = [
             "node_modules/**",
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 906300f..04326a6 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
@@ -54,7 +54,7 @@
 } from '../../../types/common';
 import {ActionType} from '../../../api/change-actions';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {SinonFakeTimers} from 'sinon/pkg/sinon-esm';
+import {SinonFakeTimers} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index cb4c9a4..422c91b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -57,7 +57,7 @@
   LabelValueToDescriptionMap,
   Hashtag,
 } from '../../../types/common';
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
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 90b9a2d..831a309 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
@@ -30,7 +30,7 @@
   someProvidersAreLoadingFirstTime$,
   topLevelActionsLatest$,
 } from '../../../services/checks/checks-model';
-import {Action, Category, RunStatus} from '../../../api/checks';
+import {Action, Category, Link, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import '../../shared/gr-avatar/gr-avatar';
 import {
@@ -180,6 +180,9 @@
   @property()
   text = '';
 
+  @property()
+  links: Link[] = [];
+
   static override get styles() {
     return [
       fontStyles,
@@ -187,6 +190,8 @@
       css`
         :host {
           display: inline-block;
+          position: relative;
+          white-space: nowrap;
         }
         .checksChip {
           color: var(--chip-color);
@@ -202,8 +207,16 @@
           position: relative;
           top: 2px;
         }
-        .checksChip:hover .text {
-          max-width: 240px;
+        .checksChip.hoverFullLength {
+          position: absolute;
+          z-index: 1;
+          display: none;
+        }
+        .checksChip.hoverFullLength .text {
+          max-width: 400px;
+        }
+        :host(:hover) .checksChip.hoverFullLength {
+          display: inline-block;
         }
         .checksChip .text {
           display: inline-block;
@@ -308,20 +321,51 @@
       ariaLabel = `${this.text} ${label} ${type}${plural}`;
     }
     const chipClass = `checksChip font-small ${icon}`;
+    const chipClassFullLength = `${chipClass} hoverFullLength`;
     const grIcon = `gr-icons:${icon}`;
+    // 15 is roughly the number of chars for the chip exceeding its 120px width.
     return html`
-      <div
-        class="${chipClass}"
-        role="link"
-        tabindex="0"
-        aria-label="${ariaLabel}"
-      >
-        <iron-icon icon="${grIcon}"></iron-icon>
+      ${this.text.length > 15
+        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, grIcon)}`
+        : ''}
+      ${this.renderChip(chipClass, ariaLabel, grIcon)}
+    `;
+  }
+
+  private renderChip(clazz: string, ariaLabel: string, icon: string) {
+    return html`
+      <div class="${clazz}" role="link" tabindex="0" aria-label="${ariaLabel}">
+        <iron-icon icon="${icon}"></iron-icon>
         <div class="text">${this.text}</div>
-        <slot></slot>
+        ${this.renderLinks()}
       </div>
     `;
   }
+
+  private renderLinks() {
+    return this.links.map(
+      link => html`
+        <a
+          href="${link.url}"
+          target="_blank"
+          @click="${this.onLinkClick}"
+          @keydown="${this.onLinkKeyDown}"
+          aria-label="Link to check details"
+          ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
+        ></a>
+      `
+    );
+  }
+
+  private onLinkKeyDown(e: KeyboardEvent) {
+    // Prevents onChipKeyDown() from reacting to <a> link keyboard events.
+    e.stopPropagation();
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
 }
 
 /** What is the maximum number of detailed checks chips? */
@@ -660,21 +704,10 @@
     return html`<gr-checks-chip
       .statusOrCategory="${statusOrCategory}"
       .text="${text}"
+      .links="${links}"
       @click="${handler}"
       @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
-      >${links.map(
-        link => html`
-          <a
-            href="${link.url}"
-            target="_blank"
-            @click="${this.onLinkClick}"
-            @keydown="${this.onLinkKeyDown}"
-            aria-label="Link to check details"
-            ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
-          ></a>
-        `
-      )}
-    </gr-checks-chip>`;
+    ></gr-checks-chip>`;
   }
 
   private onChipClick(state: ChecksTabState) {
@@ -683,16 +716,6 @@
     });
   }
 
-  private onLinkKeyDown(e: KeyboardEvent) {
-    // Prevents onConChipKeyDown() from reacting to <a> link keyboard events.
-    e.stopPropagation();
-  }
-
-  private onLinkClick(e: MouseEvent) {
-    // Prevents onChipClick() from reacting to <a> link clicks.
-    e.stopPropagation();
-  }
-
   override render() {
     const commentThreads =
       this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 6b63000..676e066 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -90,7 +90,7 @@
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {AppElementChangeViewParams} from '../../gr-app-types';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {CommentThread, UIRobot} from '../../../utils/comment-util';
@@ -445,7 +445,7 @@
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
     assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 1 as PatchSetNum);
+    assert.equal(args[2], 1 as BasePatchSetNum);
   });
 
   test('_handleDiffBaseAgainstLeft', () => {
@@ -483,7 +483,7 @@
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 3 as PatchSetNum);
+    assert.equal(args[2], 3 as BasePatchSetNum);
   });
 
   test('_handleDiffBaseAgainstLatest', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index e11d3a3..5def25a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -43,7 +43,6 @@
   createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators.js';
-import sinon from 'sinon/pkg/sinon-esm';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {queryAndAssert} from '../../../utils/common-util.js';
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 90d2049..f87c4c3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -51,7 +51,7 @@
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 
 const basicFixture = fixtureFromElement('gr-message');
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index e38e0ae..a6dc338f 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 import {PluginApi} from '../../../api/plugin';
 import {ChangeStatus} from '../../../constants/constants';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 7ca7226..f636df3 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -16,12 +16,14 @@
  */
 import '../../../styles/gr-font-styles';
 import '../../shared/gr-hovercard/gr-hovercard-shared-style';
+import '../../shared/gr-button/gr-button';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {HovercardBehaviorMixin} from '../../shared/gr-hovercard/gr-hovercard-behavior';
 import {htmlTemplate} from './gr-submit-requirement-hovercard_html';
 import {
   AccountInfo,
+  SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
@@ -53,6 +55,9 @@
   @property({type: Boolean})
   mutable?: boolean;
 
+  @property({type: Boolean})
+  expanded = false;
+
   @property({type: Array, computed: 'computeLabels(change, requirement)'})
   _labels: Label[] = [];
 
@@ -82,6 +87,16 @@
   computeIcon(status: SubmitRequirementStatus) {
     return iconForStatus(status);
   }
+
+  renderCondition(expression?: SubmitRequirementExpressionInfo) {
+    if (!expression) return '';
+
+    return expression.expression;
+  }
+
+  _handleShowConditions() {
+    this.expanded = true;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
index 6adb29e..192a812 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
@@ -43,7 +43,6 @@
     }
     .row {
       display: flex;
-      margin-top: var(--spacing-s);
     }
     .title {
       color: var(--deemphasized-text-color);
@@ -53,18 +52,37 @@
       margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
       display: flex;
     }
+    div.sectionIcon {
+      flex: 0 0 30px;
+    }
     div.sectionIcon iron-icon {
       position: relative;
       top: 2px;
       width: 20px;
       height: 20px;
     }
+    .condition {
+      background-color: var(--gray-background);
+      padding: var(--spacing-m);
+      flex-grow: 1;
+    }
+    .expression {
+      color: var(--gray-foreground);
+    }
     iron-icon.check {
       color: var(--success-foreground);
     }
     iron-icon.close {
       color: var(--warning-foreground);
     }
+    .showConditions iron-icon {
+      color: inherit;
+    }
+    div.showConditions {
+      border-top: 1px solid var(--border-color);
+      margin-top: var(--spacing-m);
+      padding: var(--spacing-m) var(--spacing-xl) 0;
+    }
   </style>
   <div id="container" role="tooltip" tabindex="-1">
     <div class="section">
@@ -113,5 +131,59 @@
         </section>
       </template>
     </div>
+    <template is="dom-if" if="[[!expanded]]">
+      <div class="showConditions">
+        <gr-button
+          link=""
+          class="showConditions"
+          on-click="_handleShowConditions"
+        >
+          View condition
+          <iron-icon icon="gr-icons:expand-more"></iron-icon
+        ></gr-button>
+      </div>
+    </template>
+    <template is="dom-if" if="[[expanded]]">
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon icon="gr-icons:description"></iron-icon>
+        </div>
+        <div class="sectionContent">[[requirement.description]]</div>
+      </div>
+      <div class="section">
+        <div class="sectionIcon"></div>
+        <div class="sectionContent condition">
+          Blocking condition:<br />
+          <span class="expression">
+            [[renderCondition(requirement.submittability_expression_result)]]
+          </span>
+        </div>
+      </div>
+      <template
+        is="dom-if"
+        if="[[requirement.applicability_expression_result]]"
+      >
+        <div class="section">
+          <div class="sectionIcon"></div>
+          <div class="sectionContent condition">
+            Application condition:<br />
+            <span class="expression">
+              [[renderCondition(requirement.applicability_expression_result)]]
+            </span>
+          </div>
+        </div>
+      </template>
+      <template is="dom-if" if="[[requirement.override_expression_result]]">
+        <div class="section">
+          <div class="sectionIcon"></div>
+          <div class="sectionContent condition">
+            Override condition:<br />
+            <span class="expression">
+              [[renderCondition(requirement.override_expression_result)]]
+            </span>
+          </div>
+        </div>
+      </template>
+    </template>
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 2dc737e..7b12b1117 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -67,6 +67,7 @@
   'footer:',
   'from:',
   'has:',
+  'has:attention',
   'has:draft',
   'has:edit',
   'has:star',
@@ -77,6 +78,7 @@
   'is:',
   'is:abandoned',
   'is:assigned',
+  'is:attention',
   'is:cherrypick',
   'is:closed',
   'is:ignored',
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 1908df0..6dd67e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -41,6 +41,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -98,7 +99,11 @@
   })
   _disableApplyFixButton = false;
 
-  layers = [new TokenHighlightLayer(this)];
+  layers = appContext.flagsService.isEnabled(
+    KnownExperimentId.TOKEN_HIGHLIGHTING
+  )
+    ? [new TokenHighlightLayer(this)]
+    : [];
 
   private refitOverlay?: () => void;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 294a78e..663ee7e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -572,6 +572,7 @@
     lineLimit: number
   ): HTMLElement {
     const contentText = this._createElement('div', 'contentText');
+    contentText.ariaLabel = text;
     const responsive = isResponsive(responsiveMode);
     let columnPos = 0;
     let textOffset = 0;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
index d606e01..9fc69b5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -21,7 +21,6 @@
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
 import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import sinon from 'sinon/pkg/sinon-esm';
 import {html, render} from 'lit-html';
 import {_testOnly_allTasks} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../test/test-utils';
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 fa3ddf4..c4fed53 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
@@ -412,7 +412,11 @@
 
   private _getLayers(path: string): DiffLayer[] {
     const layers = [];
-    layers.push(new TokenHighlightLayer(this));
+    if (
+      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
+    ) {
+      layers.push(new TokenHighlightLayer(this));
+    }
     layers.push(this.syntaxLayer);
     // Get layers from plugins (if any).
     layers.push(...this.jsAPI.getDiffLayers(path));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index b24b3ba..344f9d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -1270,7 +1270,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1324,7 +1324,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index fbdcb69..ba1abd0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -429,6 +429,11 @@
           id="highlight-image"
           style="${styleMap({
             opacity: this.showHighlight ? '1' : '0',
+            // When the highlight layer is not being shown, saving the image or
+            // opening it in a new tab from the context menu, e.g. for external
+            // comparison, should give back the source image, not the highlight
+            // layer.
+            'pointer-events': this.showHighlight ? 'auto' : 'none',
           })}"
           src="${this.diffHighlightSrc}"
         />
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 d9c4ba2..a3de30a 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
@@ -29,7 +29,6 @@
   createComment,
 } from '../../../test/test-data-generators.js';
 import {EditPatchSetNum} from '../../../types/common.js';
-import sinon from 'sinon/pkg/sinon-esm';
 import {CursorMoveResult} from '../../../api/core.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index ab27b1e..f1813a4 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -27,7 +27,7 @@
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {IronInputElement} from '@polymer/iron-input';
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-info');
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
index bb70855..b3c485a 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -24,6 +24,7 @@
 import {
   createAccountWithEmail,
   createAccountWithId,
+  createServerInfo,
 } from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-avatar');
@@ -116,9 +117,11 @@
 
   suite('config set', () => {
     setup(() => {
-      stub('gr-avatar', '_getConfig').callsFake(() =>
-        Promise.resolve({plugin: {has_avatars: true}})
-      );
+      const config = {
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      };
+      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
       element = basicFixture.instantiate();
     });
 
@@ -154,9 +157,11 @@
     let element: GrAvatar;
 
     setup(() => {
-      stub('gr-avatar', '_getConfig').callsFake(() =>
-        Promise.resolve({plugin: {has_avatars: true}})
-      );
+      const config = {
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      };
+      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
 
       element = basicFixture.instantiate();
     });
@@ -182,7 +187,7 @@
     let element: GrAvatar;
 
     setup(() => {
-      stub('gr-avatar', '_getConfig').callsFake(() => Promise.resolve({}));
+      stub('gr-avatar', '_getConfig').returns(Promise.resolve(undefined));
 
       element = basicFixture.instantiate();
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index a56f6f1..39fc7c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import sinon from 'sinon/pkg/sinon-esm';
 import '../../../test/common-test-setup-karma';
 import {createChange} from '../../../test/test-data-generators';
 import './gr-change-status';
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 39a87a2..d6aae5c 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
@@ -54,6 +54,7 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences} from '../../../api/diff';
 import {
@@ -211,6 +212,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly flagsService = appContext.flagsService;
+
   private readonly commentsService = appContext.commentsService;
 
   readonly storage = appContext.storageService;
@@ -357,7 +360,9 @@
   _getLayers(diff?: DiffInfo) {
     if (!diff) return [];
     const layers = [];
-    layers.push(new TokenHighlightLayer(this));
+    if (this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)) {
+      layers.push(new TokenHighlightLayer(this));
+    }
     layers.push(this.syntaxLayer);
     return layers;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 54d8ee8..31a1614 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -52,7 +52,7 @@
   createFixSuggestionInfo,
 } from '../../../test/test-data-generators';
 import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {CreateFixCommentEvent} from '../../../types/events';
 import {DraftInfo, UIRobot} from '../../../utils/comment-util';
 import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 69573c7..da1a782 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -158,6 +158,8 @@
       <g id="arrow-forward"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:feedback -->
       <g id="feedback"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
+      <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 8ec2607..29db685 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -23,7 +23,6 @@
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
-import sinon from 'sinon/pkg/sinon-esm';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {appContext} from '../../../services/app-context.js';
 
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index f5d6e35..0a5eeca 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -353,7 +353,7 @@
 export const fakeRun0: CheckRun = {
   pluginName: 'f0',
   internalRunId: 'f0',
-  checkName: 'FAKE Error Finder Finder Finder',
+  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
   labelName: 'Presubmit',
   isSingleAttempt: true,
   isLatestAttempt: true,
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 21f3aa4..ef5fde2 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,6 +25,7 @@
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
+  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   NEW_REPLY_DIALOG = 'UiFeature__new_reply_dialog',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
diff --git a/polygerrit-ui/app/test/@types/sinon-esm.d.ts b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
deleted file mode 100644
index 9074a7a..0000000
--- a/polygerrit-ui/app/test/@types/sinon-esm.d.ts
+++ /dev/null
@@ -1,27 +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.
- */
-
-declare module 'sinon/pkg/sinon-esm' {
-  // sinon-esm doesn't have it's own d.ts, reexport all types from sinon
-  // This is a trick - @types/sinon adds interfaces and sinon instance
-  // to a global variables/namespace. We reexport it here, so we
-  // can use in our code when importing sinon-esm
-  // eslint-disable-next-line import/no-default-export
-  export default sinon;
-  const sinon: Sinon.SinonStatic;
-  export {SinonSpy, SinonFakeTimers, SinonStubbedMember};
-}
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 5096e09..550d3df 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -33,7 +33,6 @@
   TestKeyboardShortcutBinder,
 } from './test-utils';
 import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import sinon from 'sinon/pkg/sinon-esm';
 import {safeTypesBridge} from '../utils/safe-types-util';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
 import {initGlobalVariables} from '../elements/gr-app-global-var-init';
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 156217c..a60c1d1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,7 +23,7 @@
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
-import {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {SinonSpy} from 'sinon';
 import {StorageService} from '../services/storage/gr-storage';
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 79dae41..9516dce 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -39,7 +39,13 @@
     "incremental": true,
     "experimentalDecorators": true,
 
-    "allowUmdGlobalAccess": true
+    "allowUmdGlobalAccess": true,
+
+    /* typeRoots for IDE (see tsconfig_bazel.json for Bazel) */
+    "typeRoots": [
+      "node_modules/@types",
+      "../node_modules/@types"
+    ]
   },
   // With the * pattern (without an extension), only supported files
   // are included. The supported files are .ts, .tsx, .d.ts.
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 9c2ff93..7137e23 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -2,7 +2,6 @@
   "extends": "./tsconfig_bazel.json",
   "compilerOptions": {
     "typeRoots": [
-      "./test/@types",
       "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
       "../../external/ui_npm/node_modules/@types",
       "../../external/ui_dev_npm/node_modules/@types"
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 00ebc63..a3b694f 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -22,6 +22,7 @@
   if(runUnderBazel) {
     // Run under bazel
     return [
+      `external/plugins_npm/node_modules`,
       `external/ui_npm/node_modules`,
       `external/ui_dev_npm/node_modules`
     ];
@@ -58,11 +59,11 @@
 }
 
 module.exports = function(config) {
-  const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
-  const rootDir = runUnderBazel ?
-      'polygerrit-ui/app/_pg_with_tests_out/' : localDirName + '/';
-  const testFilesLocationPattern =
-      `${rootDir}**/!(template_test_srcs)/`;
+  let root = config.root;
+  if (!root) {
+    console.warn(`--root argument not set. Falling back to __dirname.`)
+    root = path.resolve(__dirname) + '/';
+  }
   // Use --test-files to specify pattern for a test files.
   // It can be just a file name, without a path:
   // --test-files async-foreach-behavior_test.js
@@ -83,7 +84,9 @@
   } else {
     filePattern = '*_test.js';
   }
-  const testFilesPattern = testFilesLocationPattern + filePattern;
+  const testFilesPattern = root + '**/' + filePattern;
+
+  console.info(`Karma test file pattern: ${testFilesPattern}`)
   // Special patch for grep parameters (see details in the grep-patch-karam.js)
   const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
   config.set({
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
index 5fab442..940b969 100755
--- a/polygerrit-ui/karma_test.sh
+++ b/polygerrit-ui/karma_test.sh
@@ -1,4 +1,6 @@
 #!/bin/bash
 
 set -euo pipefail
-./$1 start $2 --single-run
+./$1 start $2 \
+  --root 'polygerrit-ui/app/_pg_with_tests_out/**/' \
+  --test-files '*_test.js'
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index e268a31..b8a19fc 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -30,7 +30,7 @@
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
 
-  {$email.stickyApprovalDiff}
+  {if $email.stickyApprovalDiff} ( {$email.stickyApprovalDiff} ){/if}
 
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
index d2f7bfd..ac4afb3 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -32,9 +32,11 @@
     </p>
   {/if}
 
-  {call mailTemplate.UnifiedDiff}
-    {param diffLines: $email.stickyApprovalDiffHtml /}
-  {/call}
+  {if $email.stickyApprovalDiffHtml}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $email.stickyApprovalDiffHtml /}
+    {/call}
+  {/if}
 
   <div style="white-space:pre-wrap">{$email.approvals}</div>
 
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index eedf0a1..c8d6e4b 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -142,3 +142,36 @@
             "zip -Drq $$ROOT/$@ -g .",
         ]),
     )
+
+def karma_test(name, srcs, data):
+    """Creates a Karma test target.
+
+    It can be used both for the main Gerrit js bundle, but also for plugins. So
+    it should be extremely easy to add Karma test capabilities for new plugins.
+
+    We are sharing one karma.conf.js file. If you want to customize that, then
+    consider using command line arguments that the config file can process, see
+    the `root` argument for an example.
+
+    Args:
+      name: The name of the test rule.
+      srcs: The shell script to invoke, where you can set command line
+        arguments for Karma and its config.
+      data: The bundle of JavaScript files with the tests included.
+    """
+
+    native.sh_test(
+        name = name,
+        size = "enormous",
+        srcs = srcs,
+        args = [
+            "$(location //polygerrit-ui:karma_bin)",
+            "$(location //polygerrit-ui:karma.conf.js)",
+        ],
+        data = data + [
+            "//polygerrit-ui:karma_bin",
+            "//polygerrit-ui:karma.conf.js",
+        ],
+        # Should not run sandboxed.
+        tags = ["karma", "local", "manual"],
+    )
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index b32e2bc..eb4d37a 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -16,6 +16,34 @@
 
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test")
 
+def plugin_eslint():
+    """ Convenience wrapper macro of eslint() for Gerrit js plugins
+
+    Args:
+        name: name of the rule
+    """
+    eslint(
+        name = "lint",
+        srcs = native.glob(["**/*.ts"]),
+        config = ".eslintrc.js",
+        data = [
+            "tsconfig.json",
+            "//plugins:.eslintrc.js",
+            "//plugins:.prettierrc.js",
+            "//plugins:tsconfig-plugins-base.json",
+        ],
+        extensions = [".ts"],
+        ignore = "//plugins:.eslintignore",
+        plugins = [
+            "@npm//eslint-config-google",
+            "@npm//eslint-plugin-html",
+            "@npm//eslint-plugin-import",
+            "@npm//eslint-plugin-jsdoc",
+            "@npm//eslint-plugin-prettier",
+            "@npm//gts",
+        ],
+    )
+
 def eslint(name, plugins, srcs, config, ignore, extensions = [".js"], data = []):
     """ Macro to define eslint rules for files.
 
@@ -87,7 +115,7 @@
             "*_test_require_patch.js",
             "--ignore-pattern",
             "*_test_loader.js",
-            "./", # Relative to the config file location
+            "./",  # Relative to the config file location
         ],
         # Should not run sandboxed.
         tags = [