Merge changes from topic "gr-change-view-to-ts"

* changes:
  Convert gr-change-view to typescript
  Rename files to preserve history
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index a1197ef..404f5e4 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -108,6 +108,7 @@
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
@@ -327,7 +328,7 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
 
-      try {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
         RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index c96d61a..91d2d05 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,6 +27,12 @@
   }
 
   @Override
+  public Object getCacheKey() {
+    // Treat all anonymous users as a single user
+    return "anonymous";
+  }
+
+  @Override
   public String toString() {
     return "ANONYMOUS";
   }
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 43d3c7b..825b34f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -91,6 +91,12 @@
    */
   public abstract GroupMembership getEffectiveGroups();
 
+  /**
+   * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
+   * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
+   */
+  public abstract Object getCacheKey();
+
   /** Unique name of the user on this server, if one has been assigned. */
   public Optional<String> getUserName() {
     return Optional.empty();
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index ec2eb81..75c7cda 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -413,6 +413,11 @@
     return effectiveGroups;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getAccountId();
+  }
+
   public PersonIdent newRefLogIdent() {
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 821a0c6..381819d 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,6 +36,11 @@
   }
 
   @Override
+  public String getCacheKey() {
+    return "internal";
+  }
+
+  @Override
   public boolean isInternalUser() {
     return true;
   }
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index 8a8b67a..b27e05c 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,6 +40,11 @@
     return GroupMembership.EMPTY;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getRemoteAddress();
+  }
+
   public SocketAddress getRemoteAddress() {
     return peer;
   }
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
new file mode 100644
index 0000000..b4f79d1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2018 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.cache;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
+ *
+ * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
+ * the current request and potentially need to be instantiated multiple times while serving a
+ * request.
+ *
+ * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
+ * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
+ * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
+ * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
+ * shared between the request serving thread as well as sub- or background treads.
+ *
+ * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
+ * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
+ * has the downside of not sharing any objects with background threads or executors.
+ *
+ * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
+ * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
+ * just retrieving stored values is a valid operation.
+ *
+ * <p>To prevent OOM errors on requests that would cache a lot of objects, this class enforces an
+ * internal limit after which no new elements are cached. All {@code get} calls are served by
+ * invoking the {@code Supplier} after that.
+ */
+public class PerThreadCache implements AutoCloseable {
+  private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
+  /**
+   * Cache at maximum 25 values per thread. This value was chosen arbitrarily. Some endpoints (like
+   * ListProjects) break the assumption that the data cached in a request is limited. To prevent
+   * this class from accumulating an unbound number of objects, we enforce this limit.
+   */
+  private static final int PER_THREAD_CACHE_SIZE = 25;
+
+  /**
+   * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
+   * class and a list of identifiers that in combination uniquely set the object apart form others
+   * of the same class.
+   */
+  public static final class Key<T> {
+    private final Class<T> clazz;
+    private final ImmutableList<Object> identifiers;
+
+    /**
+     * Returns a key based on the value's class and an identifier that uniquely identify the value.
+     * The identifier needs to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object identifier) {
+      return new Key<>(clazz, ImmutableList.of(identifier));
+    }
+
+    /**
+     * Returns a key based on the value's class and a set of identifiers that uniquely identify the
+     * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
+      return new Key<>(clazz, ImmutableList.copyOf(identifiers));
+    }
+
+    private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
+      this.clazz = clazz;
+      this.identifiers = identifiers;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(clazz, identifiers);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Key)) {
+        return false;
+      }
+      Key<?> other = (Key<?>) o;
+      return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
+    }
+  }
+
+  public static PerThreadCache create() {
+    checkState(CACHE.get() == null, "called create() twice on the same request");
+    PerThreadCache cache = new PerThreadCache();
+    CACHE.set(cache);
+    return cache;
+  }
+
+  @Nullable
+  public static PerThreadCache get() {
+    return CACHE.get();
+  }
+
+  public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
+    PerThreadCache cache = get();
+    return cache != null ? cache.get(key, loader) : loader.get();
+  }
+
+  private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
+
+  private PerThreadCache() {}
+
+  /**
+   * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
+   * provided {@link Supplier}.
+   */
+  public <T> T get(Key<T> key, Supplier<T> loader) {
+    @SuppressWarnings("unchecked")
+    T value = (T) cache.get(key);
+    if (value == null) {
+      value = loader.get();
+      if (cache.size() < PER_THREAD_CACHE_SIZE) {
+        cache.put(key, value);
+      }
+    }
+    return value;
+  }
+
+  @Override
+  public void close() {
+    CACHE.remove();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 49df653..d10c139 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -80,8 +81,15 @@
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
-    return new WithUserImpl(identifiedUser);
+    requireNonNull(id, "user");
+    CurrentUser user = currentUser.get();
+    if (user.isIdentifiedUser() && id.equals(user.asIdentifiedUser().getAccountId())) {
+      // What looked liked an absent user is actually the current caller. Use the per-request
+      // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+      // variables of IdentifiedUser.
+      return new WithUserImpl(user.asIdentifiedUser());
+    }
+    return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
   }
 
   @Override
@@ -101,7 +109,11 @@
     public ForProject project(Project.NameKey project) {
       try {
         ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-        return projectControlFactory.create(user, state).asForProject();
+        ProjectControl control =
+            PerThreadCache.getOrCompute(
+                PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
+                () -> projectControlFactory.create(user, state));
+        return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
         return FailedPermissionBackend.project(
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
index dac555d..3960813 100644
--- a/java/com/google/gerrit/server/query/change/GroupBackedUser.java
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -64,4 +64,9 @@
   public String getLoggableName() {
     return "GroupBackedUser with memberships: " + groups.getKnownGroups();
   }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_permissions",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// 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.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /** Binding a {@link TestGroupBackend} to test adding external groups * */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
new file mode 100644
index 0000000..5d420d3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2018 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.function.Supplier;
+import org.junit.Test;
+
+public class PerThreadCacheTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
+  @Test
+  public void key_respectsClass() {
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isEqualTo(PerThreadCache.Key.create(String.class));
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isNotEqualTo(
+            /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+                Integer.class));
+  }
+
+  @Test
+  public void key_respectsIdentifiers() {
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
+  }
+
+  @Test
+  public void endToEndCache() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
+
+      String value1 = cache.get(key1, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+
+      Supplier<String> neverCalled =
+          () -> {
+            throw new IllegalStateException("this method must not be called");
+          };
+      assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
+    }
+  }
+
+  @Test
+  public void cleanUp() {
+    PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+    }
+
+    // Create a second cache and assert that it is not connected to the first one.
+    // This ensures that the cleanup is actually working.
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value2");
+      assertThat(value1).isEqualTo("value2");
+    }
+  }
+
+  @Test
+  public void doubleInstantiationFails() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      IllegalStateException thrown =
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
+    }
+  }
+
+  @Test
+  public void enforceMaxSize() {
+    try (PerThreadCache cache = PerThreadCache.create()) {
+      // Fill the cache
+      for (int i = 0; i < 50; i++) {
+        PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
+        cache.get(key, () -> "cached value");
+      }
+      // Assert that the value was not persisted
+      PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, 1000);
+      cache.get(key, () -> "new value");
+      String value = cache.get(key, () -> "directly served");
+      assertThat(value).isEqualTo("directly served");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 1cdca1b..de23ef4 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -101,6 +101,11 @@
         }
 
         @Override
+        public Object getCacheKey() {
+          return new Object();
+        }
+
+        @Override
         public boolean isIdentifiedUser() {
           return true;
         }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 64f9392..81cb732 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -1198,6 +1198,11 @@
     }
 
     @Override
+    public Object getCacheKey() {
+      return new Object();
+    }
+
+    @Override
     public Optional<String> getUserName() {
       return Optional.ofNullable(username);
     }
diff --git a/plugins/replication b/plugins/replication
index 63fb2b4..a6a6ec5 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 63fb2b4ba85380d798acbdc076e8673353507569
+Subproject commit a6a6ec5982e41a0ee9bfe24a46be96d4f13fcaaa
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 90dac88..78b6cda 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -29,6 +29,15 @@
   removeScrollLock,
 } from '@polymer/iron-overlay-behavior/iron-scroll-manager';
 
+interface ShowAlertEventDetail {
+  message: string;
+  dismissOnNavigation?: boolean;
+}
+
+interface ReloadEventDetail {
+  clearPatchset?: boolean;
+}
+
 const HOVER_CLASS = 'hovered';
 const HIDE_CLASS = 'hide';
 
@@ -193,6 +202,19 @@
        * Hovercard elements are created outside of <gr-app>, so if you want to fire
        * events, then you probably want to do that through the target element.
        */
+
+      dispatchEventThroughTarget(eventName: string): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'show-alert',
+        detail: ShowAlertEventDetail
+      ): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'reload',
+        detail: ReloadEventDetail
+      ): void;
+
       dispatchEventThroughTarget(eventName: string, detail?: unknown) {
         if (!detail) detail = {};
         if (this._target)
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 500187a..eead4f8 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -14,7 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+// This should be the first import to install handler before any other code
+import './source-map-support-install.js';
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer.js';
diff --git a/polygerrit-ui/app/test/source-map-support-install.js b/polygerrit-ui/app/test/source-map-support-install.js
new file mode 100644
index 0000000..a8f147382
--- /dev/null
+++ b/polygerrit-ui/app/test/source-map-support-install.js
@@ -0,0 +1,20 @@
+/**
+ * @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.
+ */
+
+// The karma.conf.js file loads required module before any other modules
+// The source-map-support.js can't be imported with import ... statement
+window.sourceMapSupport.install();
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
new file mode 100644
index 0000000..adf5171
--- /dev/null
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -0,0 +1,47 @@
+/**
+ * @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.
+ */
+
+// The IntelliJ (and probably other IDEs) passes test names as a regexp in
+// the format:
+// --grep=/some regexp.../
+// But mochajs doesn't expect the '/' characters before and after the regexp.
+// The code below patches input args and removes '/' if they exists.
+function installPatch(karma) {
+  const originalKarmaStart = karma.start;
+
+  karma.start = function(config, ...args) {
+    const regexpGrepPrefix = '--grep=/';
+    const regexpGrepSuffix = '/';
+    if (config && config.args) {
+      for (let i = 0; i < config.args.length; i++) {
+        const arg = config.args[i];
+        if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
+          const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
+          config.args[i] = '--grep=' + regexpText;
+        }
+      }
+    }
+    originalKarmaStart.apply(this, [config, ...args]);
+  }
+
+}
+
+const karma = window.__karma__;
+if (karma && karma.start && !karma.__grep_patch_installed__) {
+  karma.__grep_patch_installed__ = true;
+  installPatch(karma);
+}
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 879a5c8..fb87675 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -43,6 +43,20 @@
   }
 }
 
+function runInIde() {
+  // A simple detection of IDE.
+  // Default browserNoActivityTimeout is 30 seconds. An IDE usually
+  // runs karma in background and send commands when a user wants to
+  // execute test. If interval between user executed tests is bigger than
+  // browserNoActivityTimeout, the IDE reports error and doesn't restart
+  // server.
+  // We want to increase browserNoActivityTimeout when tests run in IDE.
+  // Wd don't want to increase it in other cases, oterhise hanging tests
+  // can slow down CI.
+  return !runUnderBazel &&
+      process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+}
+
 module.exports = function(config) {
   const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
   const rootDir = runUnderBazel ?
@@ -58,7 +72,10 @@
   const testFilesPattern = (typeof config.testFiles == 'string') ?
       testFilesLocationPattern + config.testFiles :
       testFilesLocationPattern + '*_test.js';
+  // Special patch for grep parameters (see details in the grep-patch-karam.js)
+  const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
   config.set({
+    browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
     // base path that will be used to resolve all patterns (eg. files, exclude)
     basePath: '../',
     plugins: [
@@ -76,6 +93,8 @@
 
     // list of files / patterns to load in the browser
     files: [
+      ...additionalFiles,
+      getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
       getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
       getUiDevNpmFilePath('sinon/pkg/sinon.js'),
       { pattern: testFilesPattern, type: 'module' },
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 7de55aa..91b8579 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -15,7 +15,8 @@
     "karma-mocha-reporter": "^2.2.5",
     "lodash": "^4.17.15",
     "mocha": "7.2.0",
-    "sinon": "^9.0.2"
+    "sinon": "^9.0.2",
+    "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index dfc5a43..a70ded8 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -3741,7 +3741,7 @@
     socket.io-client "2.1.1"
     socket.io-parser "~3.2.0"
 
-source-map-support@~0.5.12:
+source-map-support@^0.5.19, source-map-support@~0.5.12:
   version "0.5.19"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
   integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==