Merge "Refactor evaluateAllRequirements to make ImmutableMap.copyOf unneeded"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 248cb8b..5418555 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2103,7 +2103,7 @@
 Gerrit advertises patch set downloads with the `repo download`
 command, assuming that all projects managed by this instance are
 generally worked on with the
-[repo multi-repository tool](https://gerrit.googlesource.com/git-repo).
+https://gerrit.googlesource.com/git-repo[repo multi-repository tool].
 This is not default, as not all instances will deploy repo.
 
 +
@@ -2238,7 +2238,7 @@
 or "http://example.com:8080/gerrit/" so Gerrit can output links that point
 back to itself.
 +
-Setting this is highly recommended, as its necessary for the upload
+Setting this is highly recommended, as it is necessary for the upload
 code invoked by "git push" or "repo upload" to output hyperlinks
 to the newly uploaded changes.
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 35909c7..38ce7b3 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2684,7 +2684,7 @@
 Plugins may also decide not to vote on a given change by returning an
 `Optional.empty()` (ie: the plugin is not enabled for this repository).
 
-If a plugin decides not to vote, it's name will not be displayed in the UI and
+If a plugin decides not to vote, its name will not be displayed in the UI and
 it will not be recoded in the database.
 
 .Gerrit's Pre-submit handling with three plugins
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index a24d80d..4b593ff 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -518,6 +518,7 @@
 +
 Same as <<status,status:'STATE'>>.
 
+[[is-submittable]]
 is:submittable::
 +
 True if the change is submittable according to the submit rules for
@@ -529,8 +530,6 @@
 use the
 link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
 API.
-+
-Equivalent to <<submittable,submittable:ok>>.
 
 [[mergeable]]
 is:mergeable::
@@ -837,7 +836,7 @@
 +
 Matches changes that are ready to be submitted according to one common
 label configuration. (For a more general check, use
-link:#submittable[submittable:ok].)
+link:#is-submittable[is:submittable].)
 
 `is:open (label:Verified-1 OR label:Code-Review-2)`::
 `is:open (label:Verified=reject OR label:Code-Review=reject)`::
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index debd9d8..89096e4 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.createTempDirectory;
 
 import com.google.common.io.CharSink;
 import com.google.common.io.Files;
@@ -140,7 +141,7 @@
                   + addr.getPort());
 
       // TODO(davido): Switch to memory only key resolving mode.
-      File userhome = Files.createTempDir();
+      File userhome = createTempDirectory("home-").toFile();
 
       FS fs = FS.DETECTED.setUserHome(userhome);
       File sshDir = new File(userhome, ".ssh");
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 7ed79c4..ab6d0f4 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -500,6 +500,7 @@
         }
       } catch (Throwable e) {
         logger.atSevere().withCause(e).log(
+            "%s",
             MessageFormat.format(
                 HttpServerText.get().internalErrorDuringUploadPack,
                 ServletUtils.getRepository(req)));
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index cf3562f..fcd16ae 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -178,7 +178,7 @@
         aReq.addExtension(pape);
       }
     } catch (MessageException | ConsumerException e) {
-      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s" + openidIdentifier);
+      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s", openidIdentifier);
       return new DiscoveryResult(DiscoveryResult.Status.ERROR);
     }
 
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 8395d12..fa9a820 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -89,7 +89,10 @@
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
   public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
-      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
+      ImmutableSet.of(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
       ImmutableSet.of(
diff --git a/java/com/google/gerrit/mail/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
index 4b292f3..40c5a95 100644
--- a/java/com/google/gerrit/mail/ParserUtil.java
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -115,7 +115,8 @@
     int numConsecutiveDigits = 0;
     int maxConsecutiveDigits = 0;
     int numDigitGroups = 0;
-    for (char c : s.toCharArray()) {
+    for (int i = 0; i < s.length(); i++) {
+      char c = s.charAt(i);
       if (c >= '0' && c <= '9') {
         numConsecutiveDigits++;
       } else if (numConsecutiveDigits > 0) {
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
index a59e935..a6ee366c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
@@ -127,11 +127,11 @@
                   isUserNameCaseInsensitive ? "" : "in"));
           extIdNotes.commit(metaDataUpdate);
         } catch (Exception e) {
-          logger.atSevere().withCause(e).log(e.getMessage());
+          logger.atSevere().withCause(e).log("%s", e.getMessage());
         }
       }
     } catch (DuplicateExternalIdKeyException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
       throw e;
     }
   }
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 05c5f4c..d839bce 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -65,6 +65,10 @@
     this.delegate = delegate;
   }
 
+  Repository delegate() {
+    return delegate;
+  }
+
   @Override
   public void create(boolean bare) throws IOException {
     delegate.create(bare);
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index a1ac6ce..30330eb 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -85,7 +85,12 @@
       try (Repository repo = repoManager.openRepository(p)) {
         logGcConfiguration(p, repo, aggressive);
         print(writer, "collecting garbage for \"" + p + "\":\n");
-        GarbageCollectCommand gc = Git.wrap(repo).gc();
+        GarbageCollectCommand gc =
+            Git.wrap(
+                    repo instanceof DelegateRepository
+                        ? ((DelegateRepository) repo).delegate()
+                        : repo)
+                .gc();
         gc.setAggressive(aggressive);
         logGcInfo(p, "before:", gc.getStatistics());
         gc.setProgressMonitor(
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
index 6266925..dfbe663 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager.LocalDiskRepositoryManagerModule;
 import com.google.gerrit.server.git.MultiBaseLocalDiskRepositoryManager.MultiBaseLocalDiskRepositoryManagerModule;
@@ -24,7 +25,9 @@
  * Module to install {@link MultiBaseLocalDiskRepositoryManager} rather than {@link
  * LocalDiskRepositoryManager} if needed.
  */
+@ModuleImpl(name = GitRepositoryManagerModule.MANAGER_MODULE)
 public class GitRepositoryManagerModule extends LifecycleModule {
+  public static final String MANAGER_MODULE = "git-manager";
 
   private final RepositoryConfig repoConfig;
 
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 2ee2e68..d84ce7b 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -307,13 +307,13 @@
     int nameLength = Math.max(oursName.length(), theirsName.length());
     String oursNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + nameLength + "s (%s %s)",
             oursName,
             abbreviateName(ours, NAME_ABBREV_LEN),
             oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
     String theirsNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + nameLength + "s (%s %s)",
             theirsName,
             abbreviateName(theirs, NAME_ABBREV_LEN),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 2a57d3d..52a34d9 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -192,7 +192,7 @@
         volatileTotal.addAndGet(workUnits);
       } else {
         logger.atWarning().log(
-            "Total work has been finalized on sub-task " + getName() + " and cannot be updated");
+            "Total work has been finalized on sub-task %s and cannot be updated", getName());
       }
     }
 
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 9f14926..6cdc9ae 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -140,7 +140,7 @@
     try {
       futures = new SliceScheduler(index, ok).schedule();
     } catch (ProjectsCollectionFailure e) {
-      logger.atSevere().log(e.getMessage());
+      logger.atSevere().log("%s", e.getMessage());
       return Result.create(sw, false, 0, 0);
     }
 
@@ -181,7 +181,7 @@
       return reindexProject(
           indexer, project, 0, 1, ChangeNotes.Factory.scanChangeIds(repo), done, failed);
     } catch (IOException e) {
-      logger.atSevere().log(e.getMessage());
+      logger.atSevere().log("%s", e.getMessage());
       return null;
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 66dbe80..31fbe4b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -5581,6 +5581,7 @@
         Status.SATISFIED,
         /* isLegacy= */ false,
         /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertThat(change.submittable).isTrue();
 
     // Merge the change. Submit requirements are still the same.
     gApi.changes().id(changeId).current().submit();
@@ -5647,6 +5648,7 @@
         Status.UNSATISFIED,
         /* isLegacy= */ false,
         /* submittabilityCondition= */ "label:build-cop-override=MIN");
+    assertThat(change.submittable).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
index 60368eb..86a0b56 100644
--- a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -45,7 +45,8 @@
   public int[] rawChars() {
     int[] arr = new int[raw.length()];
     int i = 0;
-    for (char c : raw.toCharArray()) {
+    for (int j = 0; j < raw.length(); j++) {
+      char c = raw.charAt(j);
       arr[i++] = c;
     }
     return arr;
diff --git a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
index cb6de34..7316074 100644
--- a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -24,7 +24,8 @@
 
   @Test
   public void validPathSeparator() {
-    for (char c : VALID_CHARACTERS.toCharArray()) {
+    for (int i = 0; i < VALID_CHARACTERS.length(); i++) {
+      char c = VALID_CHARACTERS.charAt(i);
       assertWithMessage("valid character rejected: " + c)
           .that(GitwebConfig.isValidPathSeparator(c))
           .isTrue();
@@ -33,7 +34,8 @@
 
   @Test
   public void inalidPathSeparator() {
-    for (char c : SOME_INVALID_CHARACTERS.toCharArray()) {
+    for (int i = 0; i < SOME_INVALID_CHARACTERS.length(); i++) {
+      char c = SOME_INVALID_CHARACTERS.charAt(i);
       assertWithMessage("invalid character accepted: " + c)
           .that(GitwebConfig.isValidPathSeparator(c))
           .isFalse();
diff --git a/javatests/com/google/gerrit/server/git/GarbageCollectionTest.java b/javatests/com/google/gerrit/server/git/GarbageCollectionTest.java
new file mode 100644
index 0000000..41b5d79
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/GarbageCollectionTest.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.GcConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class GarbageCollectionTest {
+  private static final Project.NameKey FOO = Project.nameKey("foo");
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Mock private GcConfig gcConfig;
+  @Mock private DelegateRepository wrapper;
+
+  private SitePaths site;
+  private Config cfg;
+
+  @Before
+  public void setup() throws Exception {
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
+    site.resolve("git").toFile().mkdir();
+    cfg = new Config();
+    cfg.setString("gerrit", null, "basePath", "git");
+  }
+
+  @Test
+  public void shouldCallGcOnDelegatedRepositoryWhenDelegateRepositoryIsPassed() throws IOException {
+    // given
+    GarbageCollection objectUnderTest = prepareObjectForTesting();
+
+    // when
+    objectUnderTest.run(ImmutableList.of(FOO), false, null);
+
+    // then
+    verify(wrapper).delegate();
+  }
+
+  private GarbageCollection prepareObjectForTesting() throws IOException {
+    LocalDiskRepositoryManager repoManager = new DelegatedRepositoryManager(site, cfg, wrapper);
+    try (Repository repo = repoManager.createRepository(FOO)) {
+      assertThat(repo).isNotNull();
+    }
+    return new GarbageCollection(
+        repoManager,
+        new GarbageCollectionQueue(),
+        gcConfig,
+        new PluginSetContext<>(new DynamicSet<>(), PluginMetrics.DISABLED_INSTANCE));
+  }
+
+  private static final class DelegatedRepositoryManager extends LocalDiskRepositoryManager {
+    private final DelegateRepository wrapper;
+
+    private DelegatedRepositoryManager(SitePaths site, Config cfg, DelegateRepository wrapper) {
+      super(site, cfg);
+      this.wrapper = wrapper;
+    }
+
+    @Override
+    public Repository openRepository(NameKey name) throws RepositoryNotFoundException {
+      Repository opened = super.openRepository(name);
+      when(wrapper.delegate()).thenReturn(opened);
+      when(wrapper.getConfig()).thenReturn(opened.getConfig());
+      return wrapper;
+    }
+  }
+}
diff --git a/plugins/gitiles b/plugins/gitiles
index 97ce60f..a0709a4 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 97ce60f8bb4dbf40dde79cf56db6425c384dabcf
+Subproject commit a0709a402ee1d4fe3921fd81e575ec48a053cc9f
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index d1c320f..17b0ba0 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -97,6 +97,11 @@
   actions?: Action[];
 
   /**
+   * Shown prominently in the change summary below the run chips.
+   */
+  summaryMessage?: string;
+
+  /**
    * Top-level links that are not associated with a specific run or result.
    * Will be shown as icons in the header of the Checks tab.
    */
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index c3655bb..40474e1 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -18,6 +18,27 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
+export enum Deduping {
+  /**
+   * Only report the event once per session, even if the event details are
+   * different.
+   */
+  EVENT_ONCE_PER_SESSION = 'EVENT_ONCE_PER_SESSION',
+  /**
+   * Only report the event once per change, even if the event details are
+   * different.
+   */
+  EVENT_ONCE_PER_CHANGE = 'EVENT_ONCE_PER_CHANGE',
+  /** Only report these exact event details once per session. */
+  DETAILS_ONCE_PER_SESSION = 'DETAILS_ONCE_PER_SESSION',
+  /** Only report these exact event details once per change. */
+  DETAILS_ONCE_PER_CHANGE = 'DETAILS_ONCE_PER_CHANGE',
+}
+export declare interface ReportingOptions {
+  /** Set this, if you don't want to report *every* time. */
+  deduping?: Deduping;
+}
+
 export declare interface ReportingPluginApi {
   reportInteraction(eventName: string, details?: EventDetails): void;
 
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 78fffe3..52747b3 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -108,4 +108,5 @@
   COMMENT_SAVED = 'comment-saved',
   DISCARD_COMMENT = 'discard-comment',
   COMMENT_DISCARDED = 'comment-discarded',
+  CHECKS_TAB_RENDERED = 'checks-tab-rendered',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index cf5d952..f8b36c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -260,7 +260,7 @@
   }
 
   _computeShowInherit(inheritsFrom?: ProjectInfo) {
-    return inheritsFrom?.id?.length ? 'show' : '';
+    return this._editing || inheritsFrom?.id?.length ? 'show' : '';
   }
 
   // TODO(TS): Unclear what is model here, provide a better explanation
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index 84cf6d9..b4fa7cc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -75,10 +75,10 @@
     ></gr-user-header>
     <h1 class="assistive-tech-only">Dashboard</h1>
     <gr-change-list
-      show-star=""
+      showstar=""
       account="[[account]]"
       preferences="[[preferences]]"
-      selected-index="[[_selectedChangeIndex]]"
+      selectedindex="[[_selectedChangeIndex]]"
       sections="[[_results]]"
       on-selected-index-changed="_handleSelectedIndexChanged"
       on-toggle-star="_handleToggleStar"
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 c90359e..3298e53 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
@@ -405,6 +405,9 @@
   @state()
   actions: Action[] = [];
 
+  @state()
+  messages: string[] = [];
+
   private showAllChips = new Map<RunStatus | Category, boolean>();
 
   private getCommentsModel = resolve(this, commentsModelToken);
@@ -447,6 +450,11 @@
     );
     subscribe(
       this,
+      this.checksModel.topLevelMessagesLatest$,
+      x => (this.messages = x)
+    );
+    subscribe(
+      this,
       this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
@@ -556,10 +564,18 @@
         .actions #moreMessage {
           display: none;
         }
+        .summaryMessage {
+          line-height: var(--line-height-normal);
+          color: var(--primary-text-color);
+        }
       `,
     ];
   }
 
+  private renderSummaryMessage() {
+    return this.messages.map(m => html`<div class="summaryMessage">${m}</div>`);
+  }
+
   private renderActions() {
     const actions = this.actions ?? [];
     const summaryActions = actions.filter(a => a.summary).slice(0, 2);
@@ -794,7 +810,8 @@
                   class="loadingSpin"
                   ?hidden="${!this.someProvidersAreLoading}"
                 ></span>
-                ${this.renderErrorMessages()}${this.renderChecksLogin()}${this.renderActions()}
+                ${this.renderErrorMessages()} ${this.renderChecksLogin()}
+                ${this.renderSummaryMessage()} ${this.renderActions()}
               </div>
             </td>
           </tr>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 18eda12..0f19016 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -2152,29 +2152,28 @@
 
     if (isLocationChange) {
       this._editingCommitMessage = false;
-      const relatedChangesLoaded = coreDataPromise.then(() => {
-        let relatedChangesPromise:
-          | Promise<RelatedChangesInfo | undefined>
-          | undefined;
-        const patchNum = this._computeLatestPatchNum(this._allPatchSets);
-        if (this._change && patchNum) {
-          relatedChangesPromise = this.restApiService
-            .getRelatedChanges(this._change._number, patchNum)
-            .then(response => {
-              if (this._change && response) {
-                this.hasParent = this._calculateHasParent(
-                  this._change.change_id,
-                  response.changes
-                );
-              }
-              return response;
-            });
-        }
-        // TODO: use returned Promise
-        this.getRelatedChangesList()?.reload(relatedChangesPromise);
-      });
-      allDataPromises.push(relatedChangesLoaded);
     }
+    const relatedChangesLoaded = coreDataPromise.then(() => {
+      let relatedChangesPromise:
+        | Promise<RelatedChangesInfo | undefined>
+        | undefined;
+      const patchNum = this._computeLatestPatchNum(this._allPatchSets);
+      if (this._change && patchNum) {
+        relatedChangesPromise = this.restApiService
+          .getRelatedChanges(this._change._number, patchNum)
+          .then(response => {
+            if (this._change && response) {
+              this.hasParent = this._calculateHasParent(
+                this._change.change_id,
+                response.changes
+              );
+            }
+            return response;
+          });
+      }
+      return this.getRelatedChangesList()?.reload(relatedChangesPromise);
+    });
+    allDataPromises.push(relatedChangesLoaded);
 
     Promise.all(allDataPromises).then(() => {
       // Loading of commments data is no longer part of this reporting
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 4ca593a..c432276 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
@@ -1448,15 +1448,27 @@
     assert.isTrue(recreateSpy.calledOnce);
   });
 
-  test('related changes are not updated after other action', async () => {
-    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
+  test('related changes are updated when loadData is called', async () => {
     await flush();
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
-    sinon.stub(relatedChanges, 'reload');
+    const reloadStub = sinon.stub(relatedChanges, 'reload');
+    stubRestApi('getMergeable').returns(
+      Promise.resolve({...createMergeable(), mergeable: true})
+    );
+
+    element.params = createAppElementChangeViewParams();
+    element.changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
+        ...createChangeViewChange(),
+      },
+    });
+
     await element.loadData(true);
     assert.isFalse(navigateToChangeStub.called);
+    assert.isTrue(reloadStub.called);
   });
 
   test('_computeCopyTextForTitle', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index a0c6b83..59dcd0e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -29,11 +29,6 @@
     :host([disabled]) .container {
       opacity: 0.5;
     }
-    .container {
-      display: flex;
-      flex-direction: column;
-      max-height: 100%;
-    }
     section {
       border-top: 1px solid var(--border-color);
       flex-shrink: 0;
@@ -261,7 +256,7 @@
     }
 
   </style>
-  <div class$="container" tabindex="-1">
+  <div tabindex="-1">
     <section class="peopleContainer">
       <gr-endpoint-decorator name="reply-reviewers">
         <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
@@ -404,7 +399,7 @@
         Saving comments...
       </span>
     </section>
-    <div class$="stickyBottom newReplyDialog">
+    <div class="stickyBottom newReplyDialog">
       <gr-endpoint-decorator name="reply-bottom">
         <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
         <section
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 4294e3f..38430b0 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -171,17 +171,17 @@
           </tr>
         </thead>
         <tbody>
-          ${submit_requirements.map(requirement =>
-            this.renderRequirement(requirement)
+          ${submit_requirements.map((requirement, index) =>
+            this.renderRequirement(requirement, index)
           )}
         </tbody>
       </table>
       ${this.disableHovercards
         ? ''
         : submit_requirements.map(
-            requirement => html`
+            (requirement, index) => html`
               <gr-submit-requirement-hovercard
-                for="requirement-${charsOnly(requirement.name)}"
+                for="requirement-${index}-${charsOnly(requirement.name)}"
                 .requirement="${requirement}"
                 .change="${this.change}"
                 .account="${this.account}"
@@ -192,9 +192,9 @@
       ${this.renderTriggerVotes()}`;
   }
 
-  renderRequirement(requirement: SubmitRequirementResultInfo) {
+  renderRequirement(requirement: SubmitRequirementResultInfo, index: number) {
     return html`
-      <tr id="requirement-${charsOnly(requirement.name)}">
+      <tr id="requirement-${index}-${charsOnly(requirement.name)}">
         <td>${this.renderStatus(requirement.status)}</td>
         <td class="name">
           <gr-limited-text
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 5a094ef..323c70f 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -85,7 +85,7 @@
         </tr>
       </thead>
       <tbody>
-        <tr id="requirement-Verified">
+        <tr id="requirement-0-Verified">
           <td>
             <iron-icon
               aria-label="satisfied"
@@ -111,7 +111,7 @@
         </tr>
       </tbody>
     </table>
-    <gr-submit-requirement-hovercard for="requirement-Verified">
+    <gr-submit-requirement-hovercard for="requirement-0-Verified">
     </gr-submit-requirement-hovercard>
   `);
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 4929b7c..f4bbc5d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -676,6 +676,7 @@
       [],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -683,6 +684,7 @@
       [],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -690,6 +692,7 @@
       [],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -697,6 +700,7 @@
       [],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -704,6 +708,7 @@
       [],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -711,6 +716,7 @@
       [],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
   }
@@ -721,6 +727,7 @@
       [fakeRun0],
       fakeActions,
       fakeLinks,
+      'ETA: 1 min',
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -728,6 +735,7 @@
       [fakeRun1],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -735,6 +743,7 @@
       [fakeRun2],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -742,6 +751,7 @@
       [fakeRun3],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -749,6 +759,7 @@
       fakeRun4Att,
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     this.checksModel.updateStateSetResults(
@@ -756,6 +767,7 @@
       [fakeRun5],
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
   }
@@ -764,7 +776,8 @@
     plugin: string,
     runs: CheckRun[],
     actions: Action[] = [],
-    links: Link[] = []
+    links: Link[] = [],
+    summaryMessage: string | undefined = undefined
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
     this.checksModel.updateStateSetResults(
@@ -772,6 +785,7 @@
       newRuns,
       actions,
       links,
+      summaryMessage,
       ChecksPatchset.LATEST
     );
   }
@@ -843,7 +857,13 @@
         <gr-button
           link
           @click="${() =>
-            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks)}"
+            this.toggle(
+              'f0',
+              [fakeRun0],
+              fakeActions,
+              fakeLinks,
+              'ETA: 1 min'
+            )}"
           >0</gr-button
         >
         <gr-button link @click="${() => this.toggle('f1', [fakeRun1])}"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index a9c30c5..0ffde6f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -26,6 +26,8 @@
 import {ChecksTabState} from '../../types/events';
 import {getAppContext} from '../../services/app-context';
 import {subscribe} from '../lit/subscription-controller';
+import {Deduping} from '../../api/reporting';
+import {Interaction} from '../../constants/reporting';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -65,6 +67,8 @@
 
   private readonly checksModel = getAppContext().checksModel;
 
+  private readonly reporting = getAppContext().reportingService;
+
   constructor() {
     super();
     subscribe(
@@ -113,6 +117,11 @@
   }
 
   override render() {
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_TAB_RENDERED,
+      this.tabState,
+      {deduping: Deduping.DETAILS_ONCE_PER_CHANGE}
+    );
     return html`
       <div class="container">
         <gr-checks-runs
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
index 10476cd..0edc57b 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
@@ -45,7 +45,7 @@
     <div class="main" slot="main">[[text]]</div>
     <gr-button
       id="signIn"
-      class$="signInLink"
+      class="signInLink"
       hidden$="[[!showSignInButton]]"
       link=""
       slot="footer"
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 8824806..9e1aaa9 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -27,8 +27,9 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
+import {AuthType} from '../../../constants/constants';
 
-const AUTH = ['OPENID', 'OAUTH'];
+const AUTH = [AuthType.OPENID, AuthType.OAUTH];
 
 export interface GrIdentities {
   $: {
@@ -104,8 +105,8 @@
   }
 
   _computeShowLinkAnotherIdentity(config?: ServerInfo) {
-    if (config?.auth?.git_basic_auth_policy) {
-      return AUTH.includes(config.auth.git_basic_auth_policy.toUpperCase());
+    if (config?.auth?.auth_type) {
+      return AUTH.includes(config.auth.auth_type);
     }
 
     return false;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index 9d8dcc5..ecc322ef7 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma';
 import './gr-identities';
 import {GrIdentities} from './gr-identities';
+import {AuthType} from '../../../constants/constants';
 import {stubRestApi} from '../../../test/test-utils';
 import {ServerInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
@@ -107,19 +108,19 @@
       ...createServerInfo(),
     };
 
-    config.auth.git_basic_auth_policy = 'OAUTH';
+    config.auth.auth_type = AuthType.OAUTH;
     assert.isTrue(element._computeShowLinkAnotherIdentity(config));
 
-    config.auth.git_basic_auth_policy = 'OpenID';
+    config.auth.auth_type = AuthType.OPENID;
     assert.isTrue(element._computeShowLinkAnotherIdentity(config));
 
-    config.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    config.auth.auth_type = AuthType.HTTP_LDAP;
     assert.isFalse(element._computeShowLinkAnotherIdentity(config));
 
-    config.auth.git_basic_auth_policy = 'LDAP';
+    config.auth.auth_type = AuthType.LDAP;
     assert.isFalse(element._computeShowLinkAnotherIdentity(config));
 
-    config.auth.git_basic_auth_policy = 'HTTP';
+    config.auth.auth_type = AuthType.HTTP;
     assert.isFalse(element._computeShowLinkAnotherIdentity(config));
 
     assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
@@ -129,7 +130,7 @@
     let config: ServerInfo = {
       ...createServerInfo(),
     };
-    config.auth.git_basic_auth_policy = 'OAUTH';
+    config.auth.auth_type = AuthType.OAUTH;
 
     element.serverConfig = config;
 
@@ -138,7 +139,7 @@
     config = {
       ...createServerInfo(),
     };
-    config.auth.git_basic_auth_policy = 'LDAP';
+    config.auth.auth_type = AuthType.LDAP;
     element.serverConfig = config;
 
     assert.isFalse(element._showLinkAnotherIdentity);
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 ee40ad5..151556c 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
@@ -72,6 +72,7 @@
 import {notDeepEqual} from '../../../utils/deep-util';
 import {resolve} from '../../../models/dependency';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {whenRendered} from '../../../utils/dom-util';
 
 const NEWLINE_PATTERN = /\n/g;
 
@@ -626,8 +627,11 @@
 
   override firstUpdated() {
     if (this.shouldScrollIntoView) {
-      this.commentBox?.focus();
-      this.scrollIntoView();
+      whenRendered(this, () => {
+        this.expandCollapseComments(false);
+        this.commentBox?.focus();
+        this.scrollIntoView({block: 'center'});
+      });
     }
   }
 
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 2bdee51..e58e594 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -122,6 +122,7 @@
   errorMessage?: string;
   /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
   loginCallback?: () => void;
+  summaryMessage?: string;
   runs: CheckRun[];
   actions: Action[];
   links: Link[];
@@ -239,6 +240,13 @@
     )
   );
 
+  public topLevelMessagesLatest$ = select(this.checksLatest$, state => {
+    const messages = Object.values(state).map(
+      providerState => providerState.summaryMessage
+    );
+    return messages.filter(m => m !== undefined) as string[];
+  });
+
   public topLevelActionsSelected$ = select(this.checksSelected$, state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
@@ -444,6 +452,7 @@
     runs: CheckRunApi[],
     actions: Action[] = [],
     links: Link[] = [],
+    summaryMessage: string | undefined,
     patchset: ChecksPatchset
   ) {
     const attemptMap = createAttemptMap(runs);
@@ -483,6 +492,7 @@
       }),
       actions: [...actions],
       links: [...links],
+      summaryMessage,
     };
     this.subject$.next(nextState);
   }
@@ -701,6 +711,7 @@
                 response.runs ?? [],
                 response.actions ?? [],
                 response.links ?? [],
+                response.summaryMessage,
                 patchset
               );
               break;
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index 1bb0f8a..c2588fe 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -120,6 +120,7 @@
       RUNS,
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     assert.isFalse(current.loading);
@@ -132,6 +133,7 @@
       RUNS,
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     assert.isFalse(current.loading);
@@ -144,6 +146,7 @@
       RUNS,
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     assert.lengthOf(current.runs, 1);
@@ -156,6 +159,7 @@
       RUNS,
       [],
       [],
+      undefined,
       ChecksPatchset.LATEST
     );
     assert.equal(
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 518716b..0da8b4c 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -16,7 +16,7 @@
  */
 import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
-import {EventDetails} from '../../api/reporting';
+import {EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {
   Execution,
@@ -113,7 +113,8 @@
   ): void;
   reportInteraction(
     eventName: string | Interaction,
-    details?: EventDetails
+    details?: EventDetails,
+    options?: ReportingOptions
   ): void;
   reportErrorDialog(message: string): void;
   setRepoName(repoName: string): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index a01e9db..bf10da9 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -18,7 +18,7 @@
 import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
 import {NumericChangeId} from '../../types/common';
-import {EventDetails} from '../../api/reporting';
+import {Deduping, EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {Finalizable} from '../registry';
 import {
@@ -285,10 +285,10 @@
   private slowRpcList: SlowRpcCall[] = [];
 
   /**
-   * Keeps track of which ids were already reported to have been executed.
-   * Execution ids should only be reported once per session.
+   * Keeps track of which ids were already reported for events that should only
+   * be reported once per session.
    */
-  private executionReported = new Set<string>();
+  private reportedIds = new Set<string>();
 
   public readonly hiddenDurationTimer = new HiddenDurationTimer();
 
@@ -815,7 +815,43 @@
     );
   }
 
-  reportInteraction(eventName: string | Interaction, details: EventDetails) {
+  /**
+   * Returns true when the event was deduped and thus should not be reported.
+   */
+  _dedup(
+    eventName: string | Interaction,
+    details: EventDetails,
+    deduping?: Deduping
+  ): boolean {
+    if (!deduping) return false;
+    let id = '';
+    switch (deduping) {
+      case Deduping.DETAILS_ONCE_PER_CHANGE:
+        id = `${eventName}-${this.reportChangeId}-${JSON.stringify(details)}`;
+        break;
+      case Deduping.DETAILS_ONCE_PER_SESSION:
+        id = `${eventName}-${JSON.stringify(details)}`;
+        break;
+      case Deduping.EVENT_ONCE_PER_CHANGE:
+        id = `${eventName}-${this.reportChangeId}`;
+        break;
+      case Deduping.EVENT_ONCE_PER_SESSION:
+        id = `${eventName}`;
+        break;
+      default:
+        throw new Error(`Invalid 'deduping' option '${deduping}'.`);
+    }
+    if (this.reportedIds.has(id)) return true;
+    this.reportedIds.add(id);
+    return false;
+  }
+
+  reportInteraction(
+    eventName: string | Interaction,
+    details: EventDetails,
+    options?: ReportingOptions
+  ) {
+    if (this._dedup(eventName, details, options?.deduping)) return;
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
@@ -827,9 +863,7 @@
   }
 
   reportExecution(name: Execution, details?: EventDetails) {
-    const id = `${name}${JSON.stringify(details)}`;
-    if (this.executionReported.has(id)) return;
-    this.executionReported.add(id);
+    if (this._dedup(name, details, Deduping.DETAILS_ONCE_PER_SESSION)) return;
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.EXECUTION,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index 8068dc00..990a5c8 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -18,6 +18,7 @@
 import '../../test/common-test-setup-karma.js';
 import {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
 import {getAppContext} from '../app-context.js';
+import {Deduping} from '../../api/reporting.js';
 suite('gr-reporting tests', () => {
   let service;
 
@@ -347,6 +348,44 @@
     assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
   });
 
+  test('dedup', () => {
+    assert.isFalse(service._dedup('a', undefined, undefined));
+    assert.isFalse(service._dedup('a', undefined, undefined));
+
+    let deduping = Deduping.EVENT_ONCE_PER_SESSION;
+    assert.isFalse(service._dedup('b', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('b', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('b', {x: 'bar'}, deduping));
+
+    deduping = Deduping.DETAILS_ONCE_PER_SESSION;
+    assert.isFalse(service._dedup('c', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('c', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('c', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('c', {x: 'bar'}, deduping));
+
+    deduping = Deduping.EVENT_ONCE_PER_CHANGE;
+    service.setChangeId(1);
+    assert.isFalse(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'bar'}, deduping));
+    service.setChangeId(2);
+    assert.isFalse(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'bar'}, deduping));
+
+    deduping = Deduping.DETAILS_ONCE_PER_CHANGE;
+    service.setChangeId(1);
+    assert.isFalse(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('e', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'bar'}, deduping));
+    service.setChangeId(2);
+    assert.isFalse(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('e', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'bar'}, deduping));
+  });
+
   suite('plugins', () => {
     setup(() => {
       service.reporter.restore();
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index b96ebe6..34f0bc1 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -490,3 +490,18 @@
   }
   return false;
 }
+
+/** Executes the given callback when the element's height is > 0. */
+export function whenRendered(el: HTMLElement, callback: () => void) {
+  if (el.clientHeight > 0) {
+    callback();
+    return;
+  }
+  const obs = new ResizeObserver(() => {
+    if (el.clientHeight > 0) {
+      callback();
+      obs.unobserve(el);
+    }
+  });
+  obs.observe(el);
+}
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index 7febbac..8f63d08 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -7,4 +7,5 @@
 sh_test(
     name = "always_pass_test",
     srcs = ["always_pass_test.sh"],
+    tags = ["no_rbe"],
 )