Merge "Revert accidental change to test output level"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 2810d1e..aceb38e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3799,6 +3799,10 @@
 If another user removed a user's vote, the user with the deleted vote will be
 added to the attention set.
 
+The request returns:
+ * '204 No Content' if the vote is deleted successfully;
+ * '404 Not Found' when the vote to be deleted is zero or not present.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 1dcd662..5efcfc6 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -150,6 +150,7 @@
 
         String refName = RefNames.fullName(changeCreation.branch());
         ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+        inserter.setGroups(getGroups(changeCreation));
         changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
         inserter.setApprovals(changeCreation.approvals());
 
@@ -243,9 +244,73 @@
     return createCommit(objectInserter, tree, parentCommits, author, committer, commitMessage);
   }
 
+  private ImmutableList<String> getGroups(TestChangeCreation changeCreation) {
+    return changeCreation
+        .parents()
+        .map(parents -> getGroups(parents))
+        .orElseGet(() -> ImmutableList.of());
+  }
+
+  private ImmutableList<String> getGroups(ImmutableList<TestCommitIdentifier> parents) {
+    return parents.stream()
+        .map(parent -> getGroups(parent))
+        .flatMap(groups -> groups.stream())
+        .collect(toImmutableList());
+  }
+
+  private ImmutableList<String> getGroups(TestCommitIdentifier parentCommit) {
+    switch (parentCommit.getKind()) {
+      case BRANCH:
+        return ImmutableList.of();
+      case CHANGE_ID:
+        return getGroupsFromChange(parentCommit.changeId());
+      case COMMIT_SHA_1:
+        return ImmutableList.of();
+      case PATCHSET_ID:
+        return getGroupsFromPatchset(parentCommit.patchsetId());
+      default:
+        throw new IllegalStateException(
+            String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
+    }
+  }
+
+  private ImmutableList<String> getGroupsFromChange(Change.Id changeId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+
+    if (changeNotes.isPresent() && changeNotes.get().getChange().isClosed()) {
+      return ImmutableList.of();
+    }
+
+    return changeNotes
+        .map(ChangeNotes::getCurrentPatchSet)
+        .map(PatchSet::groups)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %s not found and hence can't be used as parent.", changeId)));
+  }
+
+  private ImmutableList<String> getGroupsFromPatchset(PatchSet.Id patchsetId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
+
+    if (changeNotes.isPresent() && changeNotes.get().getChange().isClosed()) {
+      return ImmutableList.of();
+    }
+
+    return changeNotes
+        .map(ChangeNotes::getPatchSets)
+        .map(patchsets -> patchsets.get(patchsetId))
+        .map(PatchSet::groups)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Patchset %s not found and hence can't be used as parent.", patchsetId)));
+  }
+
   private ImmutableList<ObjectId> getParentCommits(
       Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
-
     return changeCreation
         .parents()
         .map(parents -> resolveParents(repository, revWalk, parents))
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index b40e100..dc9bc32 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -174,4 +174,14 @@
     submitted = Timestamp.from(when);
     submitter = who;
   }
+
+  public RevisionInfo getCurrentRevision() {
+    RevisionInfo currentRevisionInfo = revisions.get(currentRevision);
+    if (currentRevisionInfo.commit != null) {
+      // If all revisions are requested the commit.commit field is not populated because the commit
+      // SHA1 is already present as the key in the revisions map.
+      currentRevisionInfo.commit.commit = currentRevision;
+    }
+    return currentRevisionInfo;
+  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 92788b7..fb28d30 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -101,6 +101,7 @@
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
         break;
+      case PROFILE:
       case DASHBOARD:
         // Dashboard is preloaded queries are added later when we check user is authenticated.
       case PAGE_WITHOUT_PRELOADING:
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index afaeaf6..402e48a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -40,6 +40,7 @@
     CHANGE,
     DIFF,
     DASHBOARD,
+    PROFILE,
     PAGE_WITHOUT_PRELOADING,
   }
 
@@ -50,6 +51,7 @@
   public static final Pattern DIFF_URL_PATTERN =
       Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "(/(.+))" + "/?$");
   public static final Pattern DASHBOARD_PATTERN = Pattern.compile("/dashboard/self$");
+  public static final Pattern PROFILE_PATTERN = Pattern.compile("/profile/self$");
   public static final String ROOT_PATH = "/";
 
   // These queries should be kept in sync with PolyGerrit:
@@ -130,6 +132,11 @@
       return RequestedPage.DASHBOARD;
     }
 
+    Matcher profileMatcher = IndexPreloadingUtil.PROFILE_PATTERN.matcher(requestedPath);
+    if (profileMatcher.matches()) {
+      return RequestedPage.PROFILE;
+    }
+
     if (ROOT_PATH.equals(requestedPath)) {
       return RequestedPage.DASHBOARD;
     }
@@ -148,6 +155,7 @@
         matcher = DIFF_URL_PATTERN.matcher(requestedURL);
         break;
       case DASHBOARD:
+      case PROFILE:
       case PAGE_WITHOUT_PRELOADING:
       default:
         return Optional.empty();
@@ -172,6 +180,7 @@
         matcher = DIFF_URL_PATTERN.matcher(requestedURL);
         break;
       case DASHBOARD:
+      case PROFILE:
       case PAGE_WITHOUT_PRELOADING:
       default:
         return Optional.empty();
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 961bf9b..8319d9d 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -77,6 +77,7 @@
           "/x/*",
           "/admin/*",
           "/dashboard/*",
+          "/profile/*",
           "/groups/self",
           "/settings/*",
           "/Documentation/q/*");
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 703693d..1818b1b 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -25,6 +25,7 @@
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -43,6 +44,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TreeSet;
@@ -421,13 +423,70 @@
 
   private class ByEmail extends StringSearcher {
     @Override
+    public boolean requiresContextUser() {
+      return true;
+    }
+
+    @Override
     protected boolean matches(String input) {
       return input.contains("@");
     }
 
     @Override
-    public Stream<AccountState> search(String input) throws IOException {
-      return toAccountStates(emails.getAccountFor(input));
+    public Stream<AccountState> search(String input, CurrentUser asUser) throws IOException {
+      boolean canSeeSecondaryEmails = false;
+      try {
+        if (permissionBackend.user(asUser).test(GlobalPermission.MODIFY_ACCOUNT)) {
+          canSeeSecondaryEmails = true;
+        }
+      } catch (PermissionBackendException e) {
+        // remains false
+      }
+
+      if (canSeeSecondaryEmails) {
+        return toAccountStates(emails.getAccountFor(input));
+      }
+
+      // User cannot see secondary emails, hence search by preferred email only.
+      List<AccountState> accountStates = accountQueryProvider.get().byPreferredEmail(input);
+
+      if (accountStates.size() == 1) {
+        return Stream.of(Iterables.getOnlyElement(accountStates));
+      }
+
+      if (accountStates.size() > 1) {
+        // An email can only belong to a single account. If multiple accounts are found it means
+        // there is an inconsistency, i.e. some of the found accounts have a preferred email set
+        // that they do not own via an external ID. Hence in this case we return only the one
+        // account that actually owns the email via an external ID.
+        for (AccountState accountState : accountStates) {
+          if (accountState.externalIds().stream()
+              .map(ExternalId::email)
+              .filter(Objects::nonNull)
+              .anyMatch(email -> email.equals(input))) {
+            return Stream.of(accountState);
+          }
+        }
+
+        // None of the matched accounts owns the email, return all matches to be consistent with
+        // the behavior of Emails.getAccountFor(String) that is used above if the user can see
+        // secondary emails.
+        return accountStates.stream();
+      }
+
+      // No match by preferred email. Since users can always see their own secondary emails, check
+      // if the input matches a secondary email of the user and if yes, return the account of the
+      // user.
+      if (asUser.isIdentifiedUser()
+          && asUser.asIdentifiedUser().state().externalIds().stream()
+              .map(ExternalId::email)
+              .filter(Objects::nonNull)
+              .anyMatch(email -> email.equals(input))) {
+        return Stream.of(asUser.asIdentifiedUser().state());
+      }
+
+      // No match.
+      return Stream.empty();
     }
 
     @Override
@@ -671,6 +730,18 @@
         input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
   }
 
+  public Result resolveAsUserIgnoreVisibility(CurrentUser asUser, String input)
+      throws ConfigInvalidException, IOException {
+    return resolveAsUserIgnoreVisibility(asUser, input, AccountResolver::isActive);
+  }
+
+  public Result resolveAsUserIgnoreVisibility(
+      CurrentUser asUser, String input, Predicate<AccountState> accountActivityPredicate)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input, searchers, asUser, this::allVisiblePredicate, accountActivityPredicate);
+  }
+
   /**
    * Resolves all accounts matching the input string by name or email.
    *
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 8c3f033..13385d0 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -57,12 +57,11 @@
    * are needed it is more efficient to use {@link #getAccountsFor(String...)} as this method reads
    * the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
    *
-   * <p>In addition accounts are included that have the given email as preferred email even if they
-   * have no external ID for the preferred email. Having accounts with a preferred email that does
-   * not exist as external ID is an inconsistency, but existing functionality relies on still
-   * getting those accounts, which is why they are included. Accounts by preferred email are fetched
-   * from the account index as a fallback for email addresses that could not be resolved using
-   * {@link ExternalIds}.
+   * <p>If there is no account that owns the email via an external ID all accounts that have the
+   * email set as a preferred email are returned. Having accounts with a preferred email that does
+   * not exist as external ID is an inconsistency, but existing functionality relies on getting
+   * those accounts, which is why they are returned as a fall-back by fetching them from the account
+   * index.
    *
    * @see #getAccountsFor(String...)
    */
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 8acc925..65fef05 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -18,29 +18,38 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
 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.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -50,6 +59,11 @@
 public class RebaseUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final Provider<PersonIdent> serverIdent;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PermissionBackend permissionBackend;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
@@ -57,10 +71,20 @@
 
   @Inject
   RebaseUtil(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      IdentifiedUser.GenericFactory userFactory,
+      PermissionBackend permissionBackend,
+      ChangeResource.Factory changeResourceFactory,
+      GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       PatchSetUtil psUtil,
       RebaseChangeOp.Factory rebaseFactory) {
+    this.serverIdent = serverIdent;
+    this.userFactory = userFactory;
+    this.permissionBackend = permissionBackend;
+    this.changeResourceFactory = changeResourceFactory;
+    this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
@@ -68,6 +92,143 @@
   }
 
   /**
+   * Checks that the uploader has permissions to create a new patch set and creates a new {@link
+   * RevisionResource} that contains the uploader (aka the impersonated user) as the current user
+   * which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader.
+   *
+   * <p>The following permissions are required for the uploader:
+   *
+   * <ul>
+   *   <li>The {@code Read} permission that allows to see the change.
+   *   <li>The {@code Push} permission that allows upload.
+   *   <li>The {@code Add Patch Set} permission, required if the change is owned by another user
+   *       (change owners implicitly have this permission).
+   *   <li>The {@code Forge Author} permission if the patch set that is rebased has a forged author
+   *       (author != uploader).
+   *   <li>The {@code Forge Server} permission if the patch set that is rebased has the server
+   *       identity as the author.
+   * </ul>
+   *
+   * <p>Usually the uploader should have all these permission since they were already required for
+   * the original upload, but there is the edge case that the uploader had the permission when doing
+   * the original upload and then the permission was revoked.
+   *
+   * <p>Note that patch sets with a forged committer (committer != uploader) can be rebased on
+   * behalf of the uploader, even if the uploader doesn't have the {@code Forge Committer}
+   * permission. This is because on rebase on behalf of the uploader the uploader will become the
+   * committer of the new rebased patch set, hence for the rebased patch set the committer is no
+   * longer forged (committer == uploader) and hence the {@code Forge Committer} permission is not
+   * required.
+   *
+   * <p>Note that the {@code Rebase} permission is not required for the uploader since the {@code
+   * Rebase} permission is specifically about allowing a user to do a rebase via the web UI by
+   * clicking on the {@code REBASE} button and the uploader is not clicking on this button.
+   *
+   * <p>The permissions of the uploader are checked explicitly here so that we can return a {@code
+   * 409 Conflict} response with a proper error message if they are missing (the error message says
+   * that the permission is missing for the uploader). The normal code path also checks these
+   * permission but the exception thrown there would result in a {@code 403 Forbidden} response and
+   * the error message would wrongly look like the caller (i.e. the rebaser) is missing the
+   * permission.
+   *
+   * <p>Note that this method doesn't check permissions for the rebaser (aka the impersonating user
+   * aka the calling user). Callers should check the permissions for the rebaser before calling this
+   * method.
+   *
+   * @param rsrc the revision resource that should be rebased
+   * @param rebaseInput the request input containing options for the rebase
+   * @return revision resource that contains the uploader (aka the impersonated user) as the current
+   *     user which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader
+   */
+  public RevisionResource onBehalfOf(RevisionResource rsrc, RebaseInput rebaseInput)
+      throws IOException, PermissionBackendException, BadRequestException,
+          ResourceConflictException {
+    if (rsrc.getPatchSet().id().get() != rsrc.getChange().currentPatchSetId().get()) {
+      throw new BadRequestException(
+          "non-current patch set cannot be rebased on behalf of the uploader");
+    }
+    if (rebaseInput.allowConflicts) {
+      throw new BadRequestException(
+          "allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+    }
+
+    CurrentUser caller = rsrc.getUser();
+    Account.Id uploaderId = rsrc.getPatchSet().uploader();
+    IdentifiedUser uploader = userFactory.runAs(/*remotePeer= */ null, uploaderId, caller);
+    logger.atFine().log(
+        "%s is rebasing patch set %s of project %s on behalf of uploader %s",
+        caller.getLoggableName(),
+        rsrc.getPatchSet().id(),
+        rsrc.getProject(),
+        uploader.getLoggableName());
+
+    checkPermissionForUploader(
+        uploader,
+        rsrc.getNotes(),
+        ChangePermission.READ,
+        String.format("uploader %s cannot read change", uploader.getLoggableName()));
+    checkPermissionForUploader(
+        uploader,
+        rsrc.getNotes(),
+        ChangePermission.ADD_PATCH_SET,
+        String.format("uploader %s cannot add patch set", uploader.getLoggableName()));
+
+    try (Repository repo = repoManager.openRepository(rsrc.getProject())) {
+      RevCommit commit = repo.parseCommit(rsrc.getPatchSet().commitId());
+
+      if (!uploader.hasEmailAddress(commit.getAuthorIdent().getEmailAddress())) {
+        checkPermissionForUploader(
+            uploader,
+            rsrc.getNotes(),
+            RefPermission.FORGE_AUTHOR,
+            String.format(
+                "author of patch set %d is forged and the uploader %s cannot forge author",
+                rsrc.getPatchSet().id().get(), uploader.getLoggableName()));
+
+        if (serverIdent.get().getEmailAddress().equals(commit.getAuthorIdent().getEmailAddress())) {
+          checkPermissionForUploader(
+              uploader,
+              rsrc.getNotes(),
+              RefPermission.FORGE_SERVER,
+              String.format(
+                  "author of patch set %d is the server identity and the uploader %s cannot forge"
+                      + " the server identity",
+                  rsrc.getPatchSet().id().get(), uploader.getLoggableName()));
+        }
+      }
+    }
+
+    return new RevisionResource(
+        changeResourceFactory.create(rsrc.getNotes(), uploader), rsrc.getPatchSet());
+  }
+
+  private void checkPermissionForUploader(
+      IdentifiedUser uploader,
+      ChangeNotes changeNotes,
+      ChangePermission changePermission,
+      String errorMessage)
+      throws PermissionBackendException, ResourceConflictException {
+    try {
+      permissionBackend.user(uploader).change(changeNotes).check(changePermission);
+    } catch (AuthException e) {
+      throw new ResourceConflictException(errorMessage, e);
+    }
+  }
+
+  private void checkPermissionForUploader(
+      IdentifiedUser uploader,
+      ChangeNotes changeNotes,
+      RefPermission refPermission,
+      String errorMessage)
+      throws PermissionBackendException, ResourceConflictException {
+    try {
+      permissionBackend.user(uploader).ref(changeNotes.getChange().getDest()).check(refPermission);
+    } catch (AuthException e) {
+      throw new ResourceConflictException(errorMessage, e);
+    }
+  }
+
+  /**
    * Checks whether the given change fulfills all preconditions to be rebased.
    *
    * <p>This method does not check whether the calling user is allowed to rebase the change.
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index f6fc8db..56a0bbd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -24,6 +24,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
@@ -230,6 +231,7 @@
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
   public static final Account.Id NON_CONTRIBUTOR_ACCOUNT_ID = Account.id(-2);
+  public static final Account.Id NON_EXISTING_ACCOUNT_ID = Account.id(-1000);
 
   public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
   public static final String OPERATOR_MERGED_AFTER = "mergedafter";
@@ -1043,7 +1045,7 @@
           } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
             accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
           } else {
-            accounts = parseAccount(value);
+            accounts = parseAccountIgnoreVisibility(value);
           }
         } else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
           group = parseGroup(value).getUUID();
@@ -1071,17 +1073,17 @@
         if (accounts != null || group != null) {
           throw new QueryParseException("more than one user/group specified (" + value + ")");
         }
-        try {
-          if (value.equals(ARG_ID_OWNER)) {
-            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
-            accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
-          } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
-            accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
-          } else {
-            accounts = parseAccount(value);
-          }
-        } catch (QueryParseException qpex) {
+        if (value.equals(ARG_ID_OWNER)) {
+          accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+        } else if (value.equals(ARG_ID_NON_UPLOADER)) {
+          accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+        } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+          accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
+        } else {
+          accounts = parseAccountIgnoreVisibility(value);
+        }
+
+        if (accounts.contains(NON_EXISTING_ACCOUNT_ID)) {
           // If it doesn't match an account, see if it matches a group
           // (accounts get precedence)
           try {
@@ -1227,7 +1229,7 @@
   @Operator
   public Predicate<ChangeData> owner(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return owner(parseAccount(who, (AccountState s) -> true));
+    return owner(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
   }
 
   private Predicate<ChangeData> owner(Set<Account.Id> who) {
@@ -1240,7 +1242,7 @@
 
   private Predicate<ChangeData> ownerDefaultField(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> accounts = parseAccount(who);
+    Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
     if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
       return Predicate.any();
     }
@@ -1251,7 +1253,7 @@
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
-    return uploader(parseAccount(who, (AccountState s) -> true));
+    return uploader(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
   }
 
   private Predicate<ChangeData> uploader(Set<Account.Id> who) {
@@ -1266,7 +1268,7 @@
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
-    return attention(parseAccount(who, (AccountState s) -> true));
+    return attention(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
   }
 
   private Predicate<ChangeData> attention(Set<Account.Id> who) {
@@ -1403,7 +1405,7 @@
   @Operator
   public Predicate<ChangeData> commentby(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return commentby(parseAccount(who));
+    return commentby(parseAccountIgnoreVisibility(who));
   }
 
   private Predicate<ChangeData> commentby(Set<Account.Id> who) {
@@ -1417,7 +1419,7 @@
   @Operator
   public Predicate<ChangeData> from(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> ownerIds = parseAccount(who);
+    Set<Account.Id> ownerIds = parseAccountIgnoreVisibility(who);
     return Predicate.or(owner(ownerIds), commentby(ownerIds));
   }
 
@@ -1469,7 +1471,7 @@
   @Operator
   public Predicate<ChangeData> reviewedby(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return ChangePredicates.reviewedBy(parseAccount(who));
+    return ChangePredicates.reviewedBy(parseAccountIgnoreVisibility(who));
   }
 
   @Operator
@@ -1722,18 +1724,42 @@
     }
   }
 
-  private Set<Account.Id> parseAccount(
-      String who, java.util.function.Predicate<AccountState> activityFilter)
-      throws QueryParseException, IOException, ConfigInvalidException {
+  private Set<Account.Id> parseAccountIgnoreVisibility(String who)
+      throws QueryRequiresAuthException, IOException, ConfigInvalidException {
     try {
       return args.accountResolver
-          .resolveAsUser(args.getUser(), who, activityFilter)
+          .resolveAsUserIgnoreVisibility(args.getUser(), who)
           .asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
       }
-      throw new QueryParseException(e.getMessage(), e);
+      return ImmutableSet.of(NON_EXISTING_ACCOUNT_ID);
+    }
+  }
+
+  private Set<Account.Id> parseAccountIgnoreVisibility(
+      String who, java.util.function.Predicate<AccountState> activityFilter)
+      throws QueryRequiresAuthException, IOException, ConfigInvalidException {
+    try {
+      return args.accountResolver
+          .resolveAsUserIgnoreVisibility(args.getUser(), who, activityFilter)
+          .asNonEmptyIdSet();
+    } catch (UnresolvableAccountException e) {
+      // Thrown if no account was found.
+
+      // Users can always see their own account. This means if self was being resolved and there was
+      // no match the user wasn't logged it and the request was done anonymously.
+      if (e.isSelf()) {
+        throw new QueryRequiresAuthException(e.getMessage(), e);
+      }
+
+      // If no account is found, we don't want to fail with an error as this would allow users to
+      // probe the existence of accounts (error -> account doesn't exist, empty result -> account
+      // exists but didn't take part in any visible changes). Hence, we return a special account ID
+      // (NON_EXISTING_ACCOUNT_ID) that doesn't match any account so the query can be normally
+      // executed
+      return ImmutableSet.of(NON_EXISTING_ACCOUNT_ID);
     }
   }
 
@@ -1790,7 +1816,7 @@
 
     Predicate<ChangeData> reviewerPredicate = null;
     try {
-      Set<Account.Id> accounts = parseAccount(who);
+      Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
       if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
         reviewerPredicate =
             Predicate.or(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 0e1a218..3ac4d22 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -153,10 +153,12 @@
       }
       // Set the approval to 0 if vote is being removed.
       newApprovals.put(a.label(), (short) 0);
-      found = true;
-
-      // Set old value, as required by VoteDeleted.
-      oldApprovals.put(a.label(), a.value());
+      // If the value is 0, we treat it as already deleted, so no additional actions is required
+      if (a.value() != 0) {
+        found = true;
+        // Set old value, as required by VoteDeleted.
+        oldApprovals.put(a.label(), a.value());
+      }
       break;
     }
     if (!found) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 5368c75..3cb1870 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -19,23 +19,16 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
@@ -45,36 +38,28 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
-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.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 public class Rebase
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
-  private final Provider<PersonIdent> serverIdent;
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
   private final RebaseUtil rebaseUtil;
@@ -82,13 +67,10 @@
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final PatchSetUtil patchSetUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeResource.Factory changeResourceFactory;
   private final RebaseMetrics rebaseMetrics;
 
   @Inject
   public Rebase(
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
       RebaseUtil rebaseUtil,
@@ -96,10 +78,7 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil,
-      IdentifiedUser.GenericFactory userFactory,
-      ChangeResource.Factory changeResourceFactory,
       RebaseMetrics rebaseMetrics) {
-    this.serverIdent = serverIdent;
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
     this.rebaseUtil = rebaseUtil;
@@ -107,8 +86,6 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.patchSetUtil = patchSetUtil;
-    this.userFactory = userFactory;
-    this.changeResourceFactory = changeResourceFactory;
     this.rebaseMetrics = rebaseMetrics;
   }
 
@@ -118,7 +95,7 @@
 
     if (input.onBehalfOfUploader && !rsrc.getPatchSet().uploader().equals(rsrc.getAccountId())) {
       rsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
-      rsrc = onBehalfOf(rsrc, input);
+      rsrc = rebaseUtil.onBehalfOf(rsrc, input);
     } else {
       rsrc.permissions().check(ChangePermission.REBASE);
     }
@@ -160,143 +137,6 @@
     }
   }
 
-  /**
-   * Checks that the uploader has permissions to create a new patch set and creates a new {@link
-   * RevisionResource} that contains the uploader (aka the impersonated user) as the current user
-   * which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader.
-   *
-   * <p>The following permissions are required for the uploader:
-   *
-   * <ul>
-   *   <li>The {@code Read} permission that allows to see the change.
-   *   <li>The {@code Push} permission that allows upload.
-   *   <li>The {@code Add Patch Set} permission, required if the change is owned by another user
-   *       (change owners implicitly have this permission).
-   *   <li>The {@code Forge Author} permission if the patch set that is rebased has a forged author
-   *       (author != uploader).
-   *   <li>The {@code Forge Server} permission if the patch set that is rebased has the server
-   *       identity as the author.
-   * </ul>
-   *
-   * <p>Usually the uploader should have all these permission since they were already required for
-   * the original upload, but there is the edge case that the uploader had the permission when doing
-   * the original upload and then the permission was revoked.
-   *
-   * <p>Note that patch sets with a forged committer (committer != uploader) can be rebased on
-   * behalf of the uploader, even if the uploader doesn't have the {@code Forge Committer}
-   * permission. This is because on rebase on behalf of the uploader the uploader will become the
-   * committer of the new rebased patch set, hence for the rebased patch set the committer is no
-   * longer forged (committer == uploader) and hence the {@code Forge Committer} permission is not
-   * required.
-   *
-   * <p>Note that the {@code Rebase} permission is not required for the uploader since the {@code
-   * Rebase} permission is specifically about allowing a user to do a rebase via the web UI by
-   * clicking on the {@code REBASE} button and the uploader is not clicking on this button.
-   *
-   * <p>The permissions of the uploader are checked explicitly here so that we can return a {@code
-   * 409 Conflict} response with a proper error message if they are missing (the error message says
-   * that the permission is missing for the uploader). The normal code path also checks these
-   * permission but the exception thrown there would result in a {@code 403 Forbidden} response and
-   * the error message would wrongly look like the caller (i.e. the rebaser) is missing the
-   * permission.
-   *
-   * <p>Note that this method doesn't check permissions for the rebaser (aka the impersonating user
-   * aka the calling user). Callers should check the permissions for the rebaser before calling this
-   * method.
-   *
-   * @param rsrc the revision resource that should be rebased
-   * @param rebaseInput the request input containing options for the rebase
-   * @return revision resource that contains the uploader (aka the impersonated user) as the current
-   *     user which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader
-   */
-  private RevisionResource onBehalfOf(RevisionResource rsrc, RebaseInput rebaseInput)
-      throws IOException, PermissionBackendException, BadRequestException,
-          ResourceConflictException {
-    if (rsrc.getPatchSet().id().get() != rsrc.getChange().currentPatchSetId().get()) {
-      throw new BadRequestException(
-          "non-current patch set cannot be rebased on behalf of the uploader");
-    }
-    if (rebaseInput.allowConflicts) {
-      throw new BadRequestException(
-          "allow_conflicts and on_behalf_of_uploader are mutually exclusive");
-    }
-
-    CurrentUser caller = rsrc.getUser();
-    Account.Id uploaderId = rsrc.getPatchSet().uploader();
-    IdentifiedUser uploader = userFactory.runAs(/*remotePeer= */ null, uploaderId, caller);
-    logger.atFine().log(
-        "%s is rebasing patch set %s of project %s on behalf of uploader %s",
-        caller.getLoggableName(),
-        rsrc.getPatchSet().id(),
-        rsrc.getProject(),
-        uploader.getLoggableName());
-
-    checkPermissionForUploader(
-        uploader,
-        rsrc.getNotes(),
-        ChangePermission.READ,
-        String.format("uploader %s cannot read change", uploader.getLoggableName()));
-    checkPermissionForUploader(
-        uploader,
-        rsrc.getNotes(),
-        ChangePermission.ADD_PATCH_SET,
-        String.format("uploader %s cannot add patch set", uploader.getLoggableName()));
-
-    try (Repository repo = repoManager.openRepository(rsrc.getProject())) {
-      RevCommit commit = repo.parseCommit(rsrc.getPatchSet().commitId());
-
-      if (!uploader.hasEmailAddress(commit.getAuthorIdent().getEmailAddress())) {
-        checkPermissionForUploader(
-            uploader,
-            rsrc.getNotes(),
-            RefPermission.FORGE_AUTHOR,
-            String.format(
-                "author of patch set %d is forged and the uploader %s cannot forge author",
-                rsrc.getPatchSet().id().get(), uploader.getLoggableName()));
-
-        if (serverIdent.get().getEmailAddress().equals(commit.getAuthorIdent().getEmailAddress())) {
-          checkPermissionForUploader(
-              uploader,
-              rsrc.getNotes(),
-              RefPermission.FORGE_SERVER,
-              String.format(
-                  "author of patch set %d is the server identity and the uploader %s cannot forge"
-                      + " the server identity",
-                  rsrc.getPatchSet().id().get(), uploader.getLoggableName()));
-        }
-      }
-    }
-
-    return new RevisionResource(
-        changeResourceFactory.create(rsrc.getNotes(), uploader), rsrc.getPatchSet());
-  }
-
-  private void checkPermissionForUploader(
-      IdentifiedUser uploader,
-      ChangeNotes changeNotes,
-      ChangePermission changePermission,
-      String errorMessage)
-      throws PermissionBackendException, ResourceConflictException {
-    try {
-      permissionBackend.user(uploader).change(changeNotes).check(changePermission);
-    } catch (AuthException e) {
-      throw new ResourceConflictException(errorMessage, e);
-    }
-  }
-
-  private void checkPermissionForUploader(
-      IdentifiedUser uploader,
-      ChangeNotes changeNotes,
-      RefPermission refPermission,
-      String errorMessage)
-      throws PermissionBackendException, ResourceConflictException {
-    try {
-      permissionBackend.user(uploader).ref(changeNotes.getChange().getDest()).check(refPermission);
-    } catch (AuthException e) {
-      throw new ResourceConflictException(errorMessage, e);
-    }
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 412a8ee..745c918 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
@@ -50,6 +51,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.AttentionSetObserver;
@@ -243,6 +245,12 @@
   }
 
   class ContextImpl implements Context {
+    private final CurrentUser contextUser;
+
+    ContextImpl(@Nullable CurrentUser contextUser) {
+      this.contextUser = contextUser != null ? contextUser : user;
+    }
+
     @Override
     public RepoView getRepoView() throws IOException {
       return BatchUpdate.this.getRepoView();
@@ -270,7 +278,7 @@
 
     @Override
     public CurrentUser getUser() {
-      return user;
+      return contextUser;
     }
 
     @Override
@@ -281,6 +289,10 @@
   }
 
   private class RepoContextImpl extends ContextImpl implements RepoContext {
+    RepoContextImpl(@Nullable CurrentUser contextUser) {
+      super(contextUser);
+    }
+
     @Override
     public ObjectInserter getInserter() throws IOException {
       return getRepoView().getInserterWrapper();
@@ -296,21 +308,22 @@
     private final ChangeNotes notes;
 
     /**
-     * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
-     * PatchSet.Id only for convenience.
+     * Updates where the caller allowed us to combine potentially multiple adjustments into a single
+     * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
+     * patch set.
      */
     private final Map<PatchSet.Id, ChangeUpdate> defaultUpdates;
 
     /**
-     * Updates where the caller allowed us to combine potentially multiple adjustments into a single
-     * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
-     * patch set.
+     * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
+     * PatchSet.Id only for convenience.
      */
     private final ListMultimap<PatchSet.Id, ChangeUpdate> distinctUpdates;
 
     private boolean deleted;
 
-    ChangeContextImpl(ChangeNotes notes) {
+    ChangeContextImpl(@Nullable CurrentUser contextUser, ChangeNotes notes) {
+      super(contextUser);
       this.notes = requireNonNull(notes);
       defaultUpdates = new TreeMap<>(comparing(PatchSet.Id::get));
       distinctUpdates = ArrayListMultimap.create();
@@ -334,7 +347,7 @@
     }
 
     private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = changeUpdateFactory.create(notes, user, when);
+      ChangeUpdate u = changeUpdateFactory.create(notes, getUser(), getWhen());
       if (newChanges.containsKey(notes.getChangeId())) {
         u.setAllowWriteToNewRef(true);
       }
@@ -356,7 +369,9 @@
   private class PostUpdateContextImpl extends ContextImpl implements PostUpdateContext {
     private final Map<Change.Id, ChangeData> changeDatas;
 
-    PostUpdateContextImpl(Map<Change.Id, ChangeData> changeDatas) {
+    PostUpdateContextImpl(
+        @Nullable CurrentUser contextUser, Map<Change.Id, ChangeData> changeDatas) {
+      super(contextUser);
       this.changeDatas = changeDatas;
     }
 
@@ -385,6 +400,7 @@
   }
 
   private final GitRepositoryManager repoManager;
+  private final AccountCache accountCache;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
@@ -397,10 +413,10 @@
   private final Instant when;
   private final ZoneId zoneId;
 
-  private final ListMultimap<Change.Id, BatchUpdateOp> ops =
+  private final ListMultimap<Change.Id, OpData<BatchUpdateOp>> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
   private final Map<Change.Id, Change> newChanges = new HashMap<>();
-  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+  private final List<OpData<RepoOnlyOp>> repoOnlyOps = new ArrayList<>();
   private final Map<Change.Id, NotifyHandling> perChangeNotifyHandling = new HashMap<>();
 
   private RepoView repoView;
@@ -419,6 +435,7 @@
   BatchUpdate(
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
+      AccountCache accountCache,
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory changeNotesFactory,
       ChangeUpdate.Factory changeUpdateFactory,
@@ -430,6 +447,7 @@
       @Assisted CurrentUser user,
       @Assisted Instant when) {
     this.repoManager = repoManager;
+    this.accountCache = accountCache;
     this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.changeUpdateFactory = changeUpdateFactory;
@@ -549,48 +567,72 @@
             toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
   }
 
+  /**
+   * Adds a {@link BatchUpdate} for a change.
+   *
+   * <p>The op is executed by the user for which the {@link BatchUpdate} has been created.
+   */
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
     checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
     requireNonNull(op);
-    ops.put(id, op);
+    ops.put(id, OpData.create(op, user));
     return this;
   }
 
+  /** Adds a {@link BatchUpdate} for a change that should be executed by the given context user. */
+  public BatchUpdate addOp(Change.Id id, CurrentUser contextUser, BatchUpdateOp op) {
+    checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
+    requireNonNull(op);
+    ops.put(id, OpData.create(op, contextUser));
+    return this;
+  }
+
+  /**
+   * Adds a {@link RepoOnlyOp}.
+   *
+   * <p>The op is executed by the user for which the {@link BatchUpdate} has been created.
+   */
   public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
     checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
-    repoOnlyOps.add(op);
+    repoOnlyOps.add(OpData.create(op, user));
+    return this;
+  }
+
+  /** Adds a {@link RepoOnlyOp} that should be executed by the given context user. */
+  public BatchUpdate addRepoOnlyOp(CurrentUser contextUser, RepoOnlyOp op) {
+    checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
+    repoOnlyOps.add(OpData.create(op, contextUser));
     return this;
   }
 
   public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
-    Context ctx = new ContextImpl();
+    Context ctx = new ContextImpl(user);
     Change c = op.createChange(ctx);
     checkArgument(
         !newChanges.containsKey(c.getId()), "only one op allowed to create change %s", c.getId());
     newChanges.put(c.getId(), c);
-    ops.get(c.getId()).add(0, op);
+    ops.get(c.getId()).add(0, OpData.create(op, user));
     return this;
   }
 
   private void executeUpdateRepo() throws UpdateException, RestApiException {
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (Map.Entry<Change.Id, BatchUpdateOp> op : ops.entries()) {
+      for (Map.Entry<Change.Id, OpData<BatchUpdateOp>> e : ops.entries()) {
+        BatchUpdateOp op = e.getValue().op();
+        RepoContextImpl ctx = new RepoContextImpl(e.getValue().user());
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
                 op.getClass().getSimpleName() + "#updateRepo",
-                Metadata.builder()
-                    .projectName(project.get())
-                    .changeId(op.getKey().get())
-                    .build())) {
-          op.getValue().updateRepo(ctx);
+                Metadata.builder().projectName(project.get()).changeId(e.getKey().get()).build())) {
+          op.updateRepo(ctx);
         }
       }
 
       logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
+      for (OpData<RepoOnlyOp> opData : repoOnlyOps) {
+        RepoContextImpl ctx = new RepoContextImpl(opData.user());
+        opData.op().updateRepo(ctx);
       }
 
       if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
@@ -599,7 +641,7 @@
         // first update's executeRefUpdates has finished, hence after first repo's refs have been
         // updated, which is too late.
         onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+            project, getRepoView().getRevWalk().getObjectReader(), repoView.getCommands());
       }
     } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, RestApiException.class);
@@ -613,12 +655,14 @@
     }
   }
 
-  private void fireAttentionSetUpdateEvents(PostUpdateContext ctx) {
+  private void fireAttentionSetUpdateEvents(Map<Change.Id, ChangeData> changeDatas) {
     for (ProjectChangeKey key : attentionSetUpdates.keySet()) {
-      ChangeData change = ctx.getChangeData(key.projectName(), key.changeId());
-      AccountState account = ctx.getAccount();
+      ChangeData change =
+          changeDatas.computeIfAbsent(
+              key.changeId(), id -> changeDataFactory.create(key.projectName(), key.changeId()));
       for (AttentionSetUpdate update : attentionSetUpdates.get(key)) {
-        attentionSetObserver.fire(change, account, update, ctx.getWhen());
+        attentionSetObserver.fire(
+            change, accountCache.getEvenIfMissing(update.account()), update, when);
       }
     }
   }
@@ -709,31 +753,45 @@
     }
     handle.manager.setRefLogMessage(refLogMessage);
     handle.manager.setPushCertificate(pushCert);
-    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+    for (Map.Entry<Change.Id, Collection<OpData<BatchUpdateOp>>> e : ops.asMap().entrySet()) {
       Change.Id id = e.getKey();
-      ChangeContextImpl ctx = newChangeContext(id);
       boolean dirty = false;
+      boolean deleted = false;
+      List<ChangeUpdate> changeUpdates = new ArrayList<>();
+      ChangeContextImpl ctx = null;
       logDebug(
           "Applying %d ops for change %s: %s",
           e.getValue().size(),
           id,
           lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
-      for (BatchUpdateOp op : e.getValue()) {
+      for (OpData<BatchUpdateOp> opData : e.getValue()) {
+        if (ctx == null) {
+          ctx = newChangeContext(opData.user(), id);
+        } else if (!ctx.getUser().equals(opData.user())) {
+          ctx.defaultUpdates.values().forEach(changeUpdates::add);
+          ctx.distinctUpdates.values().forEach(changeUpdates::add);
+          ctx = newChangeContext(opData.user(), id);
+        }
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateChange",
+                opData.getClass().getSimpleName() + "#updateChange",
                 Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
-          dirty |= op.updateChange(ctx);
+          dirty |= opData.op().updateChange(ctx);
+          deleted |= ctx.deleted;
         }
       }
+      if (ctx != null) {
+        ctx.defaultUpdates.values().forEach(changeUpdates::add);
+        ctx.distinctUpdates.values().forEach(changeUpdates::add);
+      }
+
       if (!dirty) {
         logDebug("No ops reported dirty, short-circuiting");
         handle.setResult(id, ChangeResult.SKIPPED);
         continue;
       }
-      ctx.defaultUpdates.values().forEach(handle.manager::add);
-      ctx.distinctUpdates.values().forEach(handle.manager::add);
-      if (ctx.deleted) {
+      changeUpdates.forEach(handle.manager::add);
+      if (deleted) {
         logDebug("Change %s was deleted", id);
         handle.manager.deleteChange(id);
         handle.setResult(id, ChangeResult.DELETED);
@@ -744,7 +802,7 @@
     return handle;
   }
 
-  private ChangeContextImpl newChangeContext(Change.Id id) {
+  private ChangeContextImpl newChangeContext(@Nullable CurrentUser contextUser, Change.Id id) {
     logDebug("Opening change %s for update", id);
     Change c = newChanges.get(id);
     boolean isNew = c != null;
@@ -757,27 +815,30 @@
       logDebug("Change %s is new", id);
     }
     ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-    return new ChangeContextImpl(notes);
+    return new ChangeContextImpl(contextUser, notes);
   }
 
   private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
-    PostUpdateContextImpl ctx = new PostUpdateContextImpl(changeDatas);
-    for (BatchUpdateOp op : ops.values()) {
+    for (OpData<BatchUpdateOp> opData : ops.values()) {
+      PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
-          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
-        op.postUpdate(ctx);
+          TraceContext.newTimer(
+              opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        opData.op().postUpdate(ctx);
       }
     }
 
-    for (RepoOnlyOp op : repoOnlyOps) {
+    for (OpData<RepoOnlyOp> opData : repoOnlyOps) {
+      PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
-          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
-        op.postUpdate(ctx);
+          TraceContext.newTimer(
+              opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        opData.op().postUpdate(ctx);
       }
     }
     try (TraceContext.TraceTimer ignored =
         TraceContext.newTimer("fireAttentionSetUpdates#postUpdate", Metadata.empty())) {
-      fireAttentionSetUpdateEvents(ctx);
+      fireAttentionSetUpdateEvents(changeDatas);
     }
   }
 
@@ -808,4 +869,18 @@
       logger.atFine().log(msg, arg1, arg2, arg3);
     }
   }
+
+  /** Data needed to execute a {@link RepoOnlyOp} or a {@link BatchUpdateOp}. */
+  @AutoValue
+  abstract static class OpData<T extends RepoOnlyOp> {
+    /** Op that should be executed. */
+    abstract T op();
+
+    /** User that should be used to execute the {@link #op}. */
+    abstract CurrentUser user();
+
+    static <T extends RepoOnlyOp> OpData<T> create(T op, CurrentUser user) {
+      return new AutoValue_BatchUpdate_OpData<>(op, user);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 2c1739d..a9a0b70 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -2899,8 +2899,12 @@
 
     requestScopeOperations.setApiUser(user.id());
     assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("secondary"));
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id("secondary@example.com"));
     requestScopeOperations.setApiUser(admin.id());
     assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
+    assertThat(gApi.accounts().id("secondary@example.com").get()._accountId)
+        .isEqualTo(foo.id().get());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 3bfb573..43c8684 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -23,16 +24,20 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -40,9 +45,11 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.Arrays;
@@ -53,6 +60,7 @@
 
 public class QueryChangesIT extends AbstractDaemonTest {
   @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<QueryChanges> queryChangesProvider;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -364,6 +372,282 @@
     assertThat(result3).hasSize(1);
   }
 
+  /**
+   * This test verifies that querying by a non-visible account doesn't fail.
+   *
+   * <p>Change queries only return changes that are visible to the calling user. If a non-visible
+   * account participated in such a change the existence of this account is known to everyone who
+   * can see the change. Hence it's OK to that the account visibility check is skipped when querying
+   * changes by non-visible accounts. If the account is visible through any visible change these
+   * changes are returned, otherwise the result is empty (see
+   * emptyResultWhenQueryingByNonVisibleAccountAndMatchingChangesAreNotVisible()), same as for
+   * non-existing accounts (see test emptyResultWhenQueryingByNonExistingAccount()).
+   */
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void changesCanBeQueriesByNonVisibleAccounts() throws Exception {
+    String ownerEmail = "owner@example.com";
+    Account.Id nonVisibleOwner = accountOperations.newAccount().preferredEmail(ownerEmail).create();
+
+    String reviewerEmail = "reviewer@example.com";
+    Account.Id nonVisibleReviewer =
+        accountOperations.newAccount().preferredEmail(reviewerEmail).create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(nonVisibleReviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user can see the change.
+    assertThat(gApi.changes().query("change:" + changeId).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that user cannot see the other accounts.
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleOwner.get()).get());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleReviewer.get()).get());
+
+    // Verify that the change is also found if user queries for changes owned/uploaded by
+    // nonVisibleOwner.
+    assertThat(gApi.changes().query("owner:" + ownerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("uploader:" + ownerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that the change is also found if user queries for changes reviewed by
+    // nonVisibleReviewer.
+    assertThat(gApi.changes().query("reviewer:" + reviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + reviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+  }
+
+  /**
+   * This test verifies that an empty result is returned for a query by a non-existing account.
+   *
+   * <p>Such queries must not return an error so that users cannot probe whether an account exists.
+   * Since we return an empty result for non-visible accounts if there are no matched changes or non
+   * of the matched changes is visible, users could conclude the existence of a account if we would
+   * return an error for non-existing accounts.
+   */
+  @Test
+  public void emptyResultWhenQueryingByNonExistingAccount() throws Exception {
+    assertThat(gApi.changes().query("owner:non-existing@example.com").get()).isEmpty();
+    assertThat(gApi.changes().query("uploader:non-existing@example.com").get()).isEmpty();
+    assertThat(gApi.changes().query("reviewer:non-existing@example.com").get()).isEmpty();
+    assertThat(gApi.changes().query("label:Code-Review+1,user=non-existing@example.com").get())
+        .isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void emptyResultWhenQueryingByNonVisibleAccountAndMatchingChangesAreNotVisible()
+      throws Exception {
+    String ownerEmail = "owner@example.com";
+    Account.Id nonVisibleOwner = accountOperations.newAccount().preferredEmail(ownerEmail).create();
+
+    String reviewerEmail = "reviewer@example.com";
+    Account.Id nonVisibleReviewer =
+        accountOperations.newAccount().preferredEmail(reviewerEmail).create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(nonVisibleReviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    // Block read permission so that the change is not visible.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user cannot see the change.
+    assertThat(gApi.changes().query("change:" + changeId).get()).isEmpty();
+
+    // Verify that user cannot see the other accounts.
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleOwner.get()).get());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleReviewer.get()).get());
+
+    // Verify that the change is also found if user queries for changes owned/uploaded by
+    // nonVisibleOwner.
+    assertThat(gApi.changes().query("owner:" + ownerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("uploader:" + ownerEmail).get()).isEmpty();
+
+    // Verify that the change is also found if user queries for changes reviewed by
+    // nonVisibleReviewer.
+    assertThat(gApi.changes().query("reviewer:" + reviewerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + reviewerEmail).get()).isEmpty();
+  }
+
+  @Test
+  public void emptyResultWhenQueryingByNonVisibleSecondaryEmail() throws Exception {
+    String secondaryOwnerEmail = "owner-secondary@example.com";
+    Account.Id owner =
+        accountOperations
+            .newAccount()
+            .preferredEmail("owner@example.com")
+            .addSecondaryEmail(secondaryOwnerEmail)
+            .create();
+
+    String secondaryReviewerEmail = "reviewer-secondary@example.com";
+    Account.Id reviewer =
+        accountOperations
+            .newAccount()
+            .preferredEmail("reviewer@example.com")
+            .addSecondaryEmail(secondaryReviewerEmail)
+            .create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(reviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user can see the change.
+    assertThat(gApi.changes().query("change:" + changeId).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that user cannot see the other accounts by their secondary email.
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(secondaryOwnerEmail).get());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(secondaryReviewerEmail).get());
+
+    // Verify that the change is not found if user queries for changes owned/uploaded by the
+    // secondary email of the owner that is not visible to user.
+    assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get()).isEmpty();
+
+    // Verify that the change is not found if user queries for changes reviewed by the secondary
+    // email of the reviewer that is not visible to user.
+    assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+        .isEmpty();
+  }
+
+  @Test
+  public void changesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability()
+      throws Exception {
+    String secondaryOwnerEmail = "owner-secondary@example.com";
+    Account.Id owner =
+        accountOperations
+            .newAccount()
+            .preferredEmail("owner@example.com")
+            .addSecondaryEmail(secondaryOwnerEmail)
+            .create();
+
+    String secondaryReviewerEmail = "reviewer-secondary@example.com";
+    Account.Id reviewer =
+        accountOperations
+            .newAccount()
+            .preferredEmail("reviewer@example.com")
+            .addSecondaryEmail(secondaryReviewerEmail)
+            .create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(reviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user can see the other accounts by their secondary email.
+    assertThat(gApi.accounts().id(secondaryOwnerEmail).get()._accountId).isEqualTo(owner.get());
+    assertThat(gApi.accounts().id(secondaryReviewerEmail).get()._accountId)
+        .isEqualTo(reviewer.get());
+
+    // Verify that the change is found if user queries for changes owned/uploaded by the secondary
+    // email.
+    assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that the change is found if user queries for changes reviewed by the secondary email.
+    assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+  }
+
+  @Test
+  public void changesFoundWhenQueryingByOwnSecondaryEmail() throws Exception {
+    String secondaryOwnerEmail = "owner-secondary@example.com";
+    Account.Id owner =
+        accountOperations
+            .newAccount()
+            .preferredEmail("owner@example.com")
+            .addSecondaryEmail(secondaryOwnerEmail)
+            .create();
+
+    String secondaryReviewerEmail = "reviewer-secondary@example.com";
+    Account.Id reviewer =
+        accountOperations
+            .newAccount()
+            .preferredEmail("reviewer@example.com")
+            .addSecondaryEmail(secondaryReviewerEmail)
+            .create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(reviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    // Verify that the change is found if owner queries for changes owned/uploaded by their
+    // secondary email.
+    requestScopeOperations.setApiUser(owner);
+    assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that the change is found if reviewer queries for changes reviewed by their secondary
+    // email.
+    requestScopeOperations.setApiUser(reviewer);
+    assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+  }
+
   private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
     for (ChangeInfo info : results) {
       assertThat(info._moreChanges).isNull();
@@ -383,4 +667,9 @@
       }
     }
   }
+
+  private static Correspondence<ChangeInfo, Change.Id> hasChangeId() {
+    return NullAwareCorrespondence.transforming(
+        changeInfo -> Change.id(changeInfo._number), "hasChangeId");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 3531234..b7b55ee 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -322,8 +322,8 @@
       testRepo.reset("HEAD~1");
       PushOneCommit.Result r2 = createChange();
 
-      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      RevisionInfo ri2 =
+          get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
       assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
 
       Change.Id id1 = r1.getChange().getId();
@@ -332,8 +332,7 @@
       rebaseCallWithInput.call(r2.getChangeId(), in);
 
       Change.Id id2 = r2.getChange().getId();
-      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-      ri2 = ci2.revisions.get(ci2.currentRevision);
+      ri2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
       assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
 
       List<RelatedChangeAndCommitInfo> related =
@@ -352,8 +351,8 @@
       testRepo.reset("HEAD~1");
       PushOneCommit.Result r2 = createChange();
 
-      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      RevisionInfo ri2 =
+          get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
       assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
 
       // Submit first change.
@@ -367,8 +366,7 @@
       rebaseCallWithInput.call(r2.getChangeId(), in);
 
       Change.Id id2 = r2.getChange().getId();
-      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-      ri2 = ci2.revisions.get(ci2.currentRevision);
+      ri2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
       assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
 
       assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
@@ -539,7 +537,7 @@
       ChangeInfo info =
           gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
 
-      RevisionInfo r = info.revisions.get(info.currentRevision);
+      RevisionInfo r = info.getCurrentRevision();
       assertThat(r._number).isEqualTo(expectedNumRevisions);
       assertThat(r.realUploader).isNull();
 
@@ -550,10 +548,10 @@
           .isEqualTo(baseCommit);
 
       // ...and the committer and description should be correct
-      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      GitPerson committer = r.commit.committer;
       assertThat(committer.name).isEqualTo(admin.fullName());
       assertThat(committer.email).isEqualTo(admin.email());
-      String description = info.revisions.get(info.currentRevision).description;
+      String description = r.description;
       assertThat(description).isEqualTo("Rebase");
 
       if (shouldHaveApproval) {
@@ -676,7 +674,7 @@
       ChangeInfo changeInfo =
           gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
       assertThat(changeInfo.revisions).hasSize(2);
-      assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+      assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
           .isEqualTo(base.name());
 
       // Verify that the file content in the created patch set is correct.
@@ -767,8 +765,8 @@
       rebaseCallWithInput.call(r3.getChangeId(), in);
 
       Change.Id id3 = r3.getChange().getId();
-      ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-      RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
+      RevisionInfo ri3 =
+          get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
       assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
 
       assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
@@ -843,22 +841,20 @@
               fileName2,
               fileContent2Ps2)
           .assertOkStatus();
-      ChangeInfo changeInfo2 = gApi.changes().id(changeId2).get();
-      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(2);
+      assertThat(gApi.changes().id(changeId2).get().getCurrentRevision()._number).isEqualTo(2);
 
       // Rebase the first patch set of the second change
       gApi.changes().id(changeId2).revision(1).rebase();
 
       // Second change should have 3 patch sets
-      changeInfo2 = gApi.changes().id(changeId2).get();
-      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(3);
+      assertThat(gApi.changes().id(changeId2).get().getCurrentRevision()._number).isEqualTo(3);
 
       // ... and the committer and description should be correct
       ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
-      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      GitPerson committer = info.getCurrentRevision().commit.committer;
       assertThat(committer.name).isEqualTo(admin.fullName());
       assertThat(committer.email).isEqualTo(admin.email());
-      String description = info.revisions.get(info.currentRevision).description;
+      String description = info.getCurrentRevision().description;
       assertThat(description).isEqualTo("Rebase");
 
       // ... and the file contents should match with patch set 1 based on change1
@@ -936,8 +932,13 @@
       verifyChangeIsUpToDate(r4);
 
       // r5 wasn't rebased.
-      ChangeInfo r5info = gApi.changes().id(r5.getChangeId()).get(CURRENT_REVISION);
-      assertThat(r5info.revisions.get(r5info.currentRevision)._number).isEqualTo(1);
+      assertThat(
+              gApi.changes()
+                  .id(r5.getChangeId())
+                  .get(CURRENT_REVISION)
+                  .getCurrentRevision()
+                  ._number)
+          .isEqualTo(1);
 
       // Rebasing r5
       verifyRebaseChainResponse(
@@ -1081,7 +1082,7 @@
               .id(changeWithConflictId)
               .get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
       assertThat(changeInfo.revisions).hasSize(2);
-      assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+      assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
           .isEqualTo(base.name());
 
       // Verify that the file content in the created patch set is correct.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
index 5a5402b..9219ede 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -215,7 +215,7 @@
     }
 
     ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo = changeInfo2.getCurrentRevision();
     // The change had 2 patch sets before the rebase, now it should be 3
     assertThat(currentRevisionInfo._number).isEqualTo(3);
     assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
@@ -278,7 +278,7 @@
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
     ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo = changeInfo2.getCurrentRevision();
     // The change had 1 patch set before the rebase, now it should be 2
     assertThat(currentRevisionInfo._number).isEqualTo(2);
     assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
@@ -297,7 +297,7 @@
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
     changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    currentRevisionInfo = changeInfo2.getCurrentRevision();
     // The change had 2 patch sets before the rebase, now it should be 3
     assertThat(currentRevisionInfo._number).isEqualTo(3);
     assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
@@ -317,7 +317,7 @@
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
     changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    currentRevisionInfo = changeInfo2.getCurrentRevision();
     // The change had 3 patch sets before the rebase, now it should be 4
     assertThat(currentRevisionInfo._number).isEqualTo(4);
     assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
@@ -432,8 +432,8 @@
     rebaseInput.onBehalfOfUploader = true;
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
-    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     // The change had 1 patch set before the rebase, now it should be 2
     assertThat(currentRevisionInfo._number).isEqualTo(2);
     assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
@@ -531,8 +531,8 @@
     rebaseInput.onBehalfOfUploader = true;
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
-    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     // The change had 2 patch set before the rebase, now it should be 3
     assertThat(currentRevisionInfo._number).isEqualTo(3);
     assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
@@ -632,8 +632,8 @@
     rebaseInput.onBehalfOfUploader = true;
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
-    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     // The change had 1 patch set before the rebase, now it should be 2
     assertThat(currentRevisionInfo._number).isEqualTo(2);
     assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
@@ -720,8 +720,8 @@
     rebaseInput.onBehalfOfUploader = true;
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
-    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     // The change had 1 patch set before the rebase, now it should be 2
     assertThat(currentRevisionInfo._number).isEqualTo(2);
     assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
@@ -773,8 +773,8 @@
     rebaseInput.onBehalfOfUploader = true;
     gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
 
-    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     // The change had 1 patch set before the rebase, now it should be 2
     assertThat(currentRevisionInfo._number).isEqualTo(2);
     assertThat(currentRevisionInfo.commit.author.email)
@@ -871,8 +871,8 @@
     gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
 
     requestScopeOperations.setApiUser(rebaser);
-    ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    RevisionInfo revisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     assertThat(revisionInfo.actions).containsKey("rebase");
     ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
     assertThat(rebaseActionInfo.enabled).isTrue();
@@ -900,8 +900,8 @@
     gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
 
     requestScopeOperations.setApiUser(changeOwner);
-    ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased.get()).get();
-    RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    RevisionInfo revisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
     assertThat(revisionInfo.actions).containsKey("rebase");
     ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
     assertThat(rebaseActionInfo.enabled).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index c89e11a..b21f97d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -91,16 +91,26 @@
     Account.Id id =
         accountOperations
             .newAccount()
-            .preferredEmail("preferred@email")
-            .addSecondaryEmail("secondary@email")
+            .preferredEmail("preferred@eexample.com")
+            .addSecondaryEmail("secondary@example.com")
             .create();
+
     RestResponse r = userRestSession.get("/accounts/secondary/detail/");
     r.assertStatus(404);
+
+    r = userRestSession.get("/accounts/secondary@example.com/detail/");
+    r.assertStatus(404);
+
     // The admin has MODIFY_ACCOUNT permission and can see the user.
     r = adminRestSession.get("/accounts/secondary/detail/");
     r.assertStatus(200);
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertThat(info._accountId).isEqualTo(id.get());
+
+    r = adminRestSession.get("/accounts/secondary@example.com/detail/");
+    r.assertStatus(200);
+    info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+    assertThat(info._accountId).isEqualTo(id.get());
   }
 
   private static class CustomAccountTagProvider implements AccountTagProvider {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 016b1e6..6491202 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -140,6 +140,33 @@
     verifyCannotDeleteVote(true);
   }
 
+  @Test
+  public void deleteAlreadyDeletedVote_returnsNotFoundAndWithoutEmails() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    String deleteAdminVoteEndPoint =
+        "/changes/"
+            + r.getChangeId()
+            + "/reviewers/"
+            + admin.id().toString()
+            + "/votes/Code-Review";
+
+    sender.clear();
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertNoContent();
+    assertThat(sender.getMessages()).hasSize(1);
+
+    sender.clear();
+    response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertNotFound();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
   private void verifyDeleteVote(boolean onRevisionLevel) throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index cf1eee0..432a6c6 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -450,8 +450,18 @@
             .to("refs/for/master");
     r.assertOkStatus();
 
-    // assert no email notifications for user
-    assertThat(sender.getMessages()).isEmpty();
+    // Assert email notification for user.
+    // The non-visible account participated in a change that is visible to user, hence through this
+    // change user can see the non-visible account.
+    // Even if watching by the non-visible account was not possible, user could just watch all
+    // changes that are visible to them and then filter them by the non-visible account locally.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 3b97372..5bdf91f 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
@@ -37,6 +38,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeType;
@@ -147,6 +149,124 @@
   }
 
   @Test
+  public void createdChangeHasDefaultGroupsByDefault() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfBranchTipIsSpecifiedAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .tipOfBranch("refs/heads/test-branch")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasSameGroupsAsOpenParentChange() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo parentChange = getChangeFromServer(parentChangeId);
+    ImmutableList<String> parentGroups = getGroups(project, parentChangeId);
+    assertThat(parentGroups).containsExactly(parentChange.currentRevision);
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
+
+    assertThat(getGroups(project, changeId)).isEqualTo(parentGroups);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfClosedChangeIsSpecifiedAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(parentChangeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(parentChangeId.get()).current().submit();
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasSameGroupsAsPatchSetOfOpenParentChange() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+    changeOperations.change(parentChangeId).newPatchset().create();
+
+    ImmutableList<String> parentGroups = getGroups(project, parentPatchset.patchsetId());
+    assertThat(parentGroups).containsExactly(parentPatchset.commitId().name());
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .patchset(parentPatchset.patchsetId())
+            .create();
+
+    assertThat(getGroups(project, changeId)).isEqualTo(parentGroups);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfPatchSetOfClosedChangeIsSpecifiedAsParent()
+      throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+    changeOperations.change(parentChangeId).newPatchset().create();
+    gApi.changes().id(parentChangeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(parentChangeId.get()).current().submit();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .patchset(parentPatchset.patchsetId())
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfCommitIsSpecifiedAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    // Currently, the easiest way to create a commit is by creating another change.
+    Change.Id anotherChangeId = changeOperations.newChange().project(project).create();
+    ObjectId parentCommitId =
+        changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().commit(parentCommitId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
   public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
     Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
     ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
@@ -1634,6 +1754,17 @@
     return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
   }
 
+  private ImmutableList<String> getGroups(Project.NameKey projectName, Change.Id changeId) {
+    return changeDataFactory.create(projectName, changeId).currentPatchSet().groups();
+  }
+
+  private ImmutableList<String> getGroups(Project.NameKey projectName, PatchSet.Id patchSetId) {
+    return changeDataFactory
+        .create(projectName, patchSetId.changeId())
+        .patchSet(patchSetId)
+        .groups();
+  }
+
   private Correspondence<CommitInfo, String> hasSha1() {
     return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasSha1");
   }
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
index e1cccf8..9f8f494 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
@@ -52,6 +52,7 @@
   @Test
   public void preloadOnlyForSelfDashboard() throws Exception {
     assertThat(parseRequestedPage("/dashboard/self")).isEqualTo(RequestedPage.DASHBOARD);
+    assertThat(parseRequestedPage("/profile/self")).isEqualTo(RequestedPage.PROFILE);
     assertThat(parseRequestedPage("/dashboard/1085901"))
         .isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
     assertThat(parseRequestedPage("/dashboard/gerrit"))
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 07159b7..3066d9e 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.change.AddReviewersOp;
@@ -46,6 +47,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
@@ -55,6 +57,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -97,6 +101,7 @@
   @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
   @Inject private AccountManager accountManager;
   @Inject private AuthRequest.Factory authRequestFactory;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
@@ -374,6 +379,157 @@
     assertThat(diffSummaryCache.asMap()).hasSize(cacheSizeBefore + 1);
   }
 
+  @Test
+  public void executeOpsWithDifferentUsers() throws Exception {
+    Change.Id changeId = createChange();
+
+    ObjectId oldHead =
+        repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId();
+
+    CurrentUser defaultUser = user.get();
+    IdentifiedUser user1 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId());
+    IdentifiedUser user2 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId());
+
+    TestOp testOp1 = new TestOp().addReviewer(defaultUser.getAccountId());
+    TestOp testOp2 = new TestOp().addReviewer(user1.getAccountId());
+    TestOp testOp3 = new TestOp().addReviewer(user2.getAccountId());
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) {
+      bu.addOp(changeId, user1, testOp1);
+      bu.addOp(changeId, user2, testOp2);
+      bu.addOp(changeId, testOp3);
+      bu.execute();
+    }
+
+    assertThat(testOp1.updateRepoUser).isEqualTo(user1);
+    assertThat(testOp1.updateChangeUser).isEqualTo(user1);
+    assertThat(testOp1.postUpdateUser).isEqualTo(user1);
+
+    assertThat(testOp2.updateRepoUser).isEqualTo(user2);
+    assertThat(testOp2.updateChangeUser).isEqualTo(user2);
+    assertThat(testOp2.postUpdateUser).isEqualTo(user2);
+
+    assertThat(testOp3.updateRepoUser).isEqualTo(defaultUser);
+    assertThat(testOp3.updateChangeUser).isEqualTo(defaultUser);
+    assertThat(testOp3.postUpdateUser).isEqualTo(defaultUser);
+
+    // Verify that we got one meta commit per op.
+    RevCommit metaCommitForTestOp3 =
+        repo.getRepository()
+            .parseCommit(
+                repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId());
+    assertThat(metaCommitForTestOp3.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId()));
+    assertThat(metaCommitForTestOp3.getFullMessage())
+        .startsWith(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user2.getAccountId(), user2.getAccountId())
+                + "Attention:");
+
+    RevCommit metaCommitForTestOp2 =
+        repo.getRepository().parseCommit(metaCommitForTestOp3.getParent(0));
+    assertThat(metaCommitForTestOp2.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", user2.getAccountId()));
+    assertThat(metaCommitForTestOp2.getFullMessage())
+        .startsWith(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user1.getAccountId(), user1.getAccountId())
+                + "Attention:");
+
+    RevCommit metaCommitForTestOp1 =
+        repo.getRepository().parseCommit(metaCommitForTestOp2.getParent(0));
+    assertThat(metaCommitForTestOp1.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", user1.getAccountId()));
+    assertThat(metaCommitForTestOp1.getFullMessage())
+        .isEqualTo(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    defaultUser.getAccountId(), defaultUser.getAccountId()));
+
+    assertThat(metaCommitForTestOp1.getParent(0)).isEqualTo(oldHead);
+  }
+
+  @Test
+  public void executeOpsWithSameUser() throws Exception {
+    Change.Id changeId = createChange();
+
+    ObjectId oldHead =
+        repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId();
+
+    CurrentUser defaultUser = user.get();
+    IdentifiedUser user1 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId());
+    IdentifiedUser user2 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId());
+
+    TestOp testOp1 = new TestOp().addReviewer(user1.getAccountId());
+    TestOp testOp2 = new TestOp().addReviewer(user2.getAccountId());
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) {
+      bu.addOp(changeId, defaultUser, testOp1);
+      bu.addOp(changeId, testOp2);
+      bu.execute();
+    }
+
+    assertThat(testOp1.updateRepoUser).isEqualTo(defaultUser);
+    assertThat(testOp1.updateChangeUser).isEqualTo(defaultUser);
+    assertThat(testOp1.postUpdateUser).isEqualTo(defaultUser);
+
+    assertThat(testOp2.updateRepoUser).isEqualTo(defaultUser);
+    assertThat(testOp2.updateChangeUser).isEqualTo(defaultUser);
+    assertThat(testOp2.postUpdateUser).isEqualTo(defaultUser);
+
+    // Verify that we got a single meta commit (updates of both ops squashed into one commit).
+    RevCommit metaCommit =
+        repo.getRepository()
+            .parseCommit(
+                repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId());
+    assertThat(metaCommit.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId()));
+    assertThat(metaCommit.getFullMessage())
+        .startsWith(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user1.getAccountId(), user1.getAccountId())
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user2.getAccountId(), user2.getAccountId())
+                + "Attention:");
+
+    assertThat(metaCommit.getParent(0)).isEqualTo(oldHead);
+  }
+
+  private Change.Id createChange() throws Exception {
+    Change.Id id = Change.id(sequences.nextChangeId());
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.insertChange(
+          changeInserterFactory.create(
+              id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+      bu.execute();
+    }
+    return id;
+  }
+
   private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
     checkArgument(totalUpdates > 0);
     checkArgument(totalUpdates <= MAX_UPDATES);
@@ -468,4 +624,38 @@
       return true;
     }
   }
+
+  private static class TestOp implements BatchUpdateOp {
+    CurrentUser updateRepoUser;
+    CurrentUser updateChangeUser;
+    CurrentUser postUpdateUser;
+
+    private List<Account.Id> reviewersToAdd = new ArrayList<>();
+
+    TestOp addReviewer(Account.Id accountId) {
+      reviewersToAdd.add(accountId);
+      return this;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) {
+      updateRepoUser = ctx.getUser();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      updateChangeUser = ctx.getUser();
+
+      reviewersToAdd.forEach(
+          accountId ->
+              ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                  .putReviewer(accountId, ReviewerStateInternal.REVIEWER));
+      return true;
+    }
+
+    @Override
+    public void postUpdate(PostUpdateContext ctx) {
+      postUpdateUser = ctx.getUser();
+    }
+  }
 }
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index be86863..ad59edd 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -124,32 +124,7 @@
   CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
   CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
   CHECKS_STATS = 'checks-stats',
-  // The following interactions are logged for investigating a spurious bug of
-  // auto-closing draft comments.
-  COMMENTS_AUTOCLOSE_FIRST_UPDATE = 'comments-autoclose-first-update',
-  COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE = 'comments-autoclose-editing-false-save',
-  COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED = 'comments-autoclose-editing-disconnected',
-  COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED = 'comments-autoclose-editing-thread-disconnected',
-  COMMENTS_AUTOCLOSE_CHECKS_UPDATED = 'comments-autoclose-checks-updated',
-  COMMENTS_AUTOCLOSE_THREADS_UPDATED = 'comments-autoclose-threads-updated',
-  COMMENTS_AUTOCLOSE_COMMENT_REMOVED = 'comments-autoclose-comment-removed',
-  COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED = 'comments-autoclose-messages-list-created',
-  COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED = 'comments-autoclose-messages-list-updated',
-  COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED = 'comments-autoclose-thread-list-created',
-  COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED = 'comments-autoclose-thread-list-updated',
-  // The following interactions are logged for investigating a spurious bug of
-  // auto-closing diffs.
-  DIFF_AUTOCLOSE_DIFF_UNDEFINED = 'diff-autoclose-diff-undefined',
-  DIFF_AUTOCLOSE_DIFF_ONGOING = 'diff-autoclose-diff-ongoing',
-  DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE = 'diff-autoclose-reload-on-whitespace',
-  DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX = 'diff-autoclose-reload-on-syntax',
-  DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS = 'diff-autoclose-reload-filelist-prefs',
-  DIFF_AUTOCLOSE_DIFF_HOST_CREATED = 'diff-autoclose-diff-host-created',
-  DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING = 'diff-autoclose-diff-not-rendering',
-  DIFF_AUTOCLOSE_FILE_LIST_UPDATED = 'diff-autoclose-file-list-updated',
-  DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED = 'diff-autoclose-shown-files-changed',
-  // The following interaction is logged for reporting and counting a suspected
-  // Chrome bug that leads to html`` misbehavior.
-  AUTOCLOSE_HTML_PATCHED = 'autoclose-html-patched',
   CHANGE_ACTION_FIRED = 'change-action-fired',
+  BUTTON_CLICK = 'button-click',
+  LINK_CLICK = 'link-click',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 41ad3f8..a11951d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -11,7 +11,6 @@
 import '../../shared/gr-list-view/gr-list-view';
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {encodeURL} from '../../../utils/url-util';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
   BranchInfo,
@@ -28,12 +27,16 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {assertIsDefined} from '../../../utils/common-util';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
+import {
+  createRepoUrl,
+  RepoDetailView,
+  RepoViewState,
+} from '../../../models/views/repo';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
@@ -132,6 +135,8 @@
   }
 
   override render() {
+    if (!this.repo) return nothing;
+    if (!this.detailType) return nothing;
     return html`
       <gr-list-view
         .createNew=${this.loggedIn}
@@ -140,7 +145,7 @@
         .items=${this.items}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.getPath(this.repo, this.detailType)}
+        .path=${this.getPath()}
         @create-clicked=${() => {
           this.handleCreateClicked();
         }}
@@ -434,11 +439,10 @@
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  private getPath(repo?: RepoName, detailType?: RepoDetailView) {
-    // TODO: Replace with `createRepoUrl()`, but be aware that `encodeURL()`
-    // gets `false` as a second parameter here. The router pattern in gr-router
-    // does not handle the filter URLs, if the repo is not encoded!
-    return `/admin/repos/${encodeURL(repo ?? '')},${detailType}`;
+  private getPath() {
+    if (!this.repo) return '';
+    if (!this.detailType) return '';
+    return createRepoUrl({repo: this.repo, detail: this.detailType});
   }
 
   private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 36ac307..ccb750d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -47,7 +47,7 @@
   isQuickLabelInfo,
   LabelInfo,
   NumericChangeId,
-  PatchSetNum,
+  PatchSetNumber,
   RequestPayload,
   RevertSubmissionInfo,
   ReviewInput,
@@ -110,6 +110,7 @@
 import {whenVisible} from '../../../utils/dom-util';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {subscribe} from '../../lit/subscription-controller';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -410,8 +411,7 @@
   @property({type: Boolean})
   hasParent?: boolean;
 
-  @property({type: String})
-  latestPatchNum?: PatchSetNum;
+  @state() latestPatchNum?: PatchSetNumber;
 
   @property({type: String})
   commitMessage = '';
@@ -533,6 +533,11 @@
 
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
   }
 
   override connectedCallback() {
@@ -1327,7 +1332,7 @@
   }
 
   // private but used in test
-  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNumber) {
     for (const rev of Object.values(change.revisions)) {
       if (rev._number === patchNum) {
         return rev;
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 82900a3..9c01f2f 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
@@ -34,7 +34,6 @@
   ChangeSubmissionId,
   CommitId,
   NumericChangeId,
-  PatchSetNum,
   PatchSetNumber,
   RepoName,
   ReviewInput,
@@ -135,7 +134,7 @@
         },
       };
       element.changeNum = 42 as NumericChangeId;
-      element.latestPatchNum = 2 as PatchSetNum;
+      element.latestPatchNum = 2 as PatchSetNumber;
       element.account = {
         _account_id: 123 as AccountId,
       };
@@ -514,7 +513,7 @@
           rev2: {...createRevision(), _number: 2 as PatchSetNumber},
         },
       };
-      element.latestPatchNum = 2 as PatchSetNum;
+      element.latestPatchNum = 2 as PatchSetNumber;
 
       queryAndAssert<GrButton>(
         element,
@@ -1808,7 +1807,7 @@
         element.change!.is_private = false;
 
         element.changeNum = 2 as NumericChangeId;
-        element.latestPatchNum = 2 as PatchSetNum;
+        element.latestPatchNum = 2 as PatchSetNumber;
 
         await element.updateComplete;
         await element.reload();
@@ -1862,7 +1861,7 @@
         element.change!.is_private = true;
 
         element.changeNum = 2 as NumericChangeId;
-        element.latestPatchNum = 2 as PatchSetNum;
+        element.latestPatchNum = 2 as PatchSetNumber;
 
         await element.updateComplete;
         await element.reload();
@@ -2331,7 +2330,7 @@
       const reloadStub = sinon.stub(element, 'reload');
       element.changeNum = 123 as NumericChangeId;
       assert.isFalse(reloadStub.called);
-      element.latestPatchNum = 456 as PatchSetNum;
+      element.latestPatchNum = 456 as PatchSetNumber;
       assert.isFalse(reloadStub.called);
     });
 
@@ -2427,7 +2426,7 @@
       setup(async () => {
         cleanup = sinon.stub();
         element.changeNum = 42 as NumericChangeId;
-        element.latestPatchNum = 12 as PatchSetNum;
+        element.latestPatchNum = 12 as PatchSetNumber;
         element.change = {
           ...createChangeViewChange(),
           revisions: createRevisions(element.latestPatchNum as number),
@@ -2695,7 +2694,7 @@
       // set the following properties
       element.change = createChangeViewChange();
       element.changeNum = 42 as NumericChangeId;
-      element.latestPatchNum = 2 as PatchSetNum;
+      element.latestPatchNum = 2 as PatchSetNumber;
 
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
       await element.updateComplete;
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 2246671..d07a6ac 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
@@ -1345,7 +1345,6 @@
         .changeNum=${this.changeNum}
         .changeStatus=${this.change?.status}
         .commitNum=${this.commitInfo?.commit}
-        .latestPatchNum=${computeLatestPatchNum(this.allPatchSets)}
         .commitMessage=${this.latestCommitMessage}
         .editPatchsetLoaded=${this.patchRange
           ? hasEditPatchsetLoaded(this.patchRange)
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index ad75253..999190e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -51,7 +51,7 @@
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
-import {Interaction, Timing} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
 import {select} from '../../../utils/observable-util';
 import {resolve} from '../../../models/dependency';
@@ -78,7 +78,6 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {HtmlPatched} from '../../../utils/lit-util';
 import {
   createDiffUrl,
   createEditUrl,
@@ -309,13 +308,6 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
-
   shortcutsController = new ShortcutController(this);
 
   private readonly getNavigation = resolve(this, navigationToken);
@@ -1015,25 +1007,12 @@
     );
   }
 
-  // for DIFF_AUTOCLOSE logging purposes only
-  private shownFilesOld: NormalizedFileInfo[] = this.shownFiles;
-
   private renderShownFiles() {
     const showDynamicColumns = this.computeShowDynamicColumns();
     const showPrependedDynamicColumns =
       this.computeShowPrependedDynamicColumns();
     const sizeBarLayout = this.computeSizeBarLayout();
 
-    // for DIFF_AUTOCLOSE logging purposes only
-    if (
-      this.shownFilesOld.length > 0 &&
-      this.shownFiles !== this.shownFilesOld
-    ) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED
-      );
-    }
-    this.shownFilesOld = this.shownFiles;
     return incrementalRepeat({
       values: this.shownFiles,
       mapFn: (f, i) =>
@@ -1084,7 +1063,7 @@
       </div>
       ${when(
         this.isFileExpanded(file.__path),
-        () => this.patched.html`
+        () => html`
           <gr-diff-host
             ?noAutoRender=${true}
             ?showLoadFailure=${true}
@@ -1658,17 +1637,6 @@
     this.reporting.fileListDisplayed();
   }
 
-  protected override updated(): void {
-    // for DIFF_AUTOCLOSE logging purposes only
-    const ids = this.diffs.map(d => d.uid);
-    if (ids.length > 0) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_FILE_LIST_UPDATED,
-        {l: ids.length, ids: ids.slice(0, 10)}
-      );
-    }
-  }
-
   // TODO: Move into files-model.
   // visible for testing
   async updateCleanlyMergedPaths() {
@@ -1787,10 +1755,6 @@
     if (!this.diffs.length) {
       return;
     }
-    this.reporting.reportInteraction(
-      Interaction.DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS
-    );
-
     // Re-render all expanded diffs sequentially.
     this.renderInOrder(this.expandedFiles, this.diffs);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 72969a7..45f24d7 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -128,9 +128,6 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
-  // for COMMENTS_AUTOCLOSE logging purposes only
-  readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
   constructor() {
     super();
     this.addEventListener('click', e => this.handleClick(e));
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index c46f4fc..972b871 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -43,7 +43,6 @@
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
 import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text';
-import {Interaction} from '../../../constants/reporting';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -353,21 +352,6 @@
         this.changeNum = x;
       }
     );
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED
-    );
-  }
-
-  override updated(): void {
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    const messages = this.shadowRoot!.querySelectorAll('gr-message');
-    if (messages.length > 0) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED,
-        {uid: messages[0].uid}
-      );
-    }
   }
 
   override willUpdate(changedProperties: PropertyValues): void {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 599e38b..7015900 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -42,11 +42,8 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {repeat} from 'lit/directives/repeat.js';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
-import {getAppContext} from '../../../services/app-context';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
-import {Interaction} from '../../../constants/reporting';
-import {HtmlPatched} from '../../../utils/lit-util';
 import {userModelToken} from '../../../models/user/user-model';
 import {specialFilePathCompare} from '../../../utils/path-list-util';
 
@@ -206,17 +203,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly reporting = getAppContext().reportingService;
-
   private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
-
   constructor() {
     super();
     subscribe(
@@ -234,10 +222,6 @@
       () => this.getUserModel().account$,
       x => (this.account = x)
     );
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED
-    );
   }
 
   override willUpdate(changed: PropertyValues) {
@@ -341,17 +325,6 @@
     ];
   }
 
-  override updated(): void {
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread');
-    if (threads.length > 0) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED,
-        {uid: threads[0].uid}
-      );
-    }
-  }
-
   override render() {
     return html`
       ${this.renderDropdown()}
@@ -425,16 +398,16 @@
           index === 0 || threads[index - 1].path !== threads[index].path;
         const separator =
           index !== 0 && isFirst
-            ? this.patched.html`<div class="thread-separator"></div>`
+            ? html`<div class="thread-separator"></div>`
             : undefined;
         const commentThread = this.renderCommentThread(thread, isFirst);
-        return this.patched.html`${separator}${commentThread}`;
+        return html`${separator}${commentThread}`;
       }
     );
   }
 
   private renderCommentThread(thread: CommentThread, isFirst: boolean) {
-    return this.patched.html`
+    return html`
       <gr-comment-thread
         .thread=${thread}
         show-file-path
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index f3be5c4..5071e4f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -72,7 +72,6 @@
 import {changeModelToken} from '../../models/change/change-model';
 import {getAppContext} from '../../services/app-context';
 import {when} from 'lit/directives/when.js';
-import {HtmlPatched} from '../../utils/lit-util';
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
 import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
@@ -818,13 +817,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
-
   constructor() {
     super();
     subscribe(
@@ -1502,7 +1494,7 @@
           ${repeat(
             filtered,
             result => result.internalResultId,
-            (result?: RunResult) => this.patched.html`
+            (result?: RunResult) => html`
               <gr-result-row
                 class=${charsOnly(result!.checkName)}
                 .result=${result}
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 43a6819..0fa5503 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
@@ -64,7 +64,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {Timing, Interaction} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {
@@ -336,9 +336,6 @@
 
   private checksSubscription?: Subscription;
 
-  // for DIFF_AUTOCLOSE logging purposes only
-  readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
   constructor() {
     super();
     this.syntaxLayer = new GrSyntaxLayerWorker(
@@ -386,23 +383,6 @@
         this.prefs = diffPreferences;
       }
     );
-    this.logForDiffAutoClose();
-  }
-
-  // for DIFF_AUTOCLOSE logging purposes only
-  private logForDiffAutoClose() {
-    this.reporting.reportInteraction(
-      Interaction.DIFF_AUTOCLOSE_DIFF_HOST_CREATED,
-      {uid: this.uid}
-    );
-    setTimeout(() => {
-      if (!this.hasReloadBeenCalledOnce) {
-        this.reporting.reportInteraction(
-          Interaction.DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING,
-          {uid: this.uid}
-        );
-      }
-    }, /* 10 seconds */ 10000);
   }
 
   override connectedCallback() {
@@ -586,14 +566,7 @@
     return this.reloadPromise;
   }
 
-  // for DIFF_AUTOCLOSE logging purposes only
-  private reloadOngoing = false;
-
-  // for DIFF_AUTOCLOSE logging purposes only
-  private hasReloadBeenCalledOnce = false;
-
   async reloadInternal(shouldReportMetric?: boolean) {
-    this.hasReloadBeenCalledOnce = true;
     this.reporting.time(Timing.DIFF_TOTAL);
     this.reporting.time(Timing.DIFF_LOAD);
     // TODO: Find better names for these 3 clear/cancel methods. Ideally the
@@ -606,10 +579,6 @@
     this.diff = undefined;
     this.errorMessage = null;
     const whitespaceLevel = this.getIgnoreWhitespace();
-    if (this.reloadOngoing) {
-      this.reporting.reportInteraction(Interaction.DIFF_AUTOCLOSE_DIFF_ONGOING);
-    }
-    this.reloadOngoing = true;
 
     try {
       // We are carefully orchestrating operations that have to wait for another
@@ -619,11 +588,6 @@
       // assets in parallel.
       const layerPromise = this.initLayers();
       const diff = await this.getDiff();
-      if (diff === undefined) {
-        this.reporting.reportInteraction(
-          Interaction.DIFF_AUTOCLOSE_DIFF_UNDEFINED
-        );
-      }
       this.loadedWhitespaceLevel = whitespaceLevel;
       this.reportDiff(diff);
 
@@ -670,7 +634,6 @@
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails());
-      this.reloadOngoing = false;
     }
   }
 
@@ -758,9 +721,6 @@
     const idToEl = new Map<string, GrDiffCheckResult>();
     const checkEls = this.getCheckEls();
     const dontRemove = new Set<GrDiffCheckResult>();
-    let createdCount = 0;
-    let updatedCount = 0;
-    let removedCount = 0;
     const checksCount = checks.length;
     const checkElsCount = checkEls.length;
     if (checksCount === 0 && checkElsCount === 0) return;
@@ -775,23 +735,16 @@
       if (existingEl) {
         existingEl.result = check;
         dontRemove.add(existingEl);
-        updatedCount++;
       } else {
         const newEl = this.createCheckEl(check);
         dontRemove.add(newEl);
-        createdCount++;
       }
     }
     // Remove all check els that don't have a matching check anymore.
     for (const el of checkEls) {
       if (dontRemove.has(el)) continue;
       el.remove();
-      removedCount++;
     }
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_CHECKS_UPDATED,
-      {createdCount, updatedCount, removedCount, checksCount, checkElsCount}
-    );
   }
 
   /**
@@ -1106,9 +1059,6 @@
       }
     }
     const dontRemove = new Set<GrCommentThread>();
-    let createdCount = 0;
-    let updatedCount = 0;
-    let removedCount = 0;
     const threadCount = threads.length;
     const threadElCount = threadEls.length;
     if (threadCount === 0 && threadElCount === 0) return;
@@ -1150,12 +1100,10 @@
       ) {
         existingThreadEl.thread = thread;
         dontRemove.add(existingThreadEl);
-        updatedCount++;
       } else {
         const threadEl = this.createThreadElement(thread);
         this.attachThreadElement(threadEl);
         dontRemove.add(threadEl);
-        createdCount++;
       }
     }
     // Remove all threads that are no longer existing.
@@ -1165,13 +1113,8 @@
       // might be unsaved and thus not be reflected in `threads` yet, so let's
       // keep them open.
       if (threadEl.editing && threadEl.thread?.comments.length === 0) continue;
-      removedCount++;
       threadEl.remove();
     }
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_THREADS_UPDATED,
-      {createdCount, updatedCount, removedCount, threadCount, threadElCount}
-    );
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
       thread => thread.ported && thread.rangeInfoLost
@@ -1371,9 +1314,6 @@
       preferredWhitespaceLevel !== loadedWhitespaceLevel &&
       !noRenderOnPrefsChange
     ) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE
-      );
       return this.reload();
     }
   }
@@ -1392,9 +1332,6 @@
     if (oldPrefs?.syntax_highlighting === prefs.syntax_highlighting) return;
 
     if (!noRenderOnPrefsChange) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX
-      );
       return this.reload();
     }
   }
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index d66fec0..d6a14ed 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -20,6 +20,7 @@
   initPerformanceReporter,
   initErrorReporter,
   initWebVitals,
+  initClickReporter,
 } from '../services/gr-reporting/gr-reporting_impl';
 import {Finalizable} from '../services/registry';
 
@@ -34,6 +35,7 @@
     initPerformanceReporter(reportingService);
     initWebVitals(reportingService);
     initErrorReporter(reportingService);
+    initClickReporter(reportingService);
   }
   window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 75974e5..b44a16b 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -11,6 +11,7 @@
 import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map.js';
+import {Interaction} from '../../../constants/reporting';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -228,6 +229,8 @@
       return;
     }
 
-    this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
+    this.reporting.reportInteraction(Interaction.BUTTON_CLICK, {
+      path: getEventPath(e),
+    });
   }
 }
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 062aa54..aba0aea 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
@@ -70,8 +70,6 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {whenRendered} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
-import {HtmlPatched} from '../../../utils/lit-util';
 import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
@@ -254,8 +252,6 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly reporting = getAppContext().reportingService;
-
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -263,16 +259,6 @@
     () => getAppContext().reportingService
   );
 
-  // for COMMENTS_AUTOCLOSE logging purposes only
-  readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
-
   constructor() {
     super();
     this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
@@ -322,15 +308,6 @@
     );
   }
 
-  override disconnectedCallback() {
-    if (this.editing) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED
-      );
-    }
-    super.disconnectedCallback();
-  }
-
   static override get styles() {
     return [
       a11yStyles,
@@ -519,16 +496,15 @@
       (this.messageId
         ? comment.change_message_id !== this.messageId
         : !this.unresolved);
-    return this.patched.html`
+    return html`
       <gr-comment
         .comment=${comment}
         .comments=${this.thread!.comments}
         ?initially-collapsed=${initiallyCollapsed}
         ?robot-button-disabled=${robotButtonDisabled}
         ?show-patchset=${this.showPatchset}
-        ?show-ported-comment=${
-          this.showPortedComment && comment.id === this.rootId
-        }
+        ?show-ported-comment=${this.showPortedComment &&
+        comment.id === this.rootId}
         @reply-to-comment=${this.handleReplyToComment}
         @copy-comment-link=${this.handleCopyLink}
         @comment-editing-changed=${(
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 0edfe03..f7e8edd 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -57,7 +57,6 @@
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
 import {changeModelToken} from '../../../models/change/change-model';
-import {Interaction} from '../../../constants/reporting';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {isBase64FileContent} from '../../../api/rest-api';
 import {createDiffUrl} from '../../../models/views/change';
@@ -318,11 +317,6 @@
   override disconnectedCallback() {
     // Clean up emoji dropdown.
     if (this.textarea) this.textarea.closeDropdown();
-    if (this.editing) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
-      );
-    }
     super.disconnectedCallback();
   }
 
@@ -938,10 +932,6 @@
     this.unresolved = this.comment.unresolved ?? true;
     if (isUnsaved(this.comment)) this.editing = true;
     if (isDraftOrUnsaved(this.comment)) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
-        {editing: this.editing, unsaved: isUnsaved(this.comment)}
-      );
       this.collapsed = false;
     } else {
       this.collapsed = !!this.initiallyCollapsed;
@@ -1210,9 +1200,6 @@
           await this.rawSave(messageToSave, {showToast: true});
         }
       }
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
-      );
       if (!this.permanentEditingMode) {
         this.editing = false;
       }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index cb466c3..e8575ff 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -26,7 +26,7 @@
 
 import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
 import {ValueChangedEvent} from '../../../types/events';
-import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {ImageDiffAction} from '../../../api/diff';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
@@ -683,7 +683,7 @@
   }
 
   fireAction(detail: ImageDiffAction) {
-    fireNoBubbleNoCompose(this, 'image-diff-action', detail);
+    fire(this, 'image-diff-action', detail);
   }
 
   selectBase() {
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 06e8204..5b6e852 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -17,6 +17,7 @@
   Timing,
 } from '../../constants/reporting';
 import {onCLS, onFID, onLCP, Metric, onINP} from 'web-vitals';
+import {getEventPath, isElementTarget} from '../../utils/dom-util';
 
 // Latency reporting constants.
 
@@ -186,6 +187,22 @@
   });
 }
 
+export function initClickReporter(reportingService: ReportingService) {
+  document.addEventListener('click', (e: MouseEvent) => {
+    const anchorEl = e
+      .composedPath()
+      .find(el => isElementTarget(el) && el.tagName.toUpperCase() === 'A') as
+      | HTMLAnchorElement
+      | undefined;
+    if (!anchorEl) return;
+    reportingService.reportInteraction(Interaction.LINK_CLICK, {
+      path: getEventPath(e),
+      link: anchorEl.href,
+      text: anchorEl.innerText,
+    });
+  });
+}
+
 export function initWebVitals(reportingService: ReportingService) {
   function reportWebVitalMetric(name: Timing, metric: Metric) {
     let score = metric.value;
diff --git a/polygerrit-ui/app/utils/lit-util.ts b/polygerrit-ui/app/utils/lit-util.ts
deleted file mode 100644
index 7ffab89..0000000
--- a/polygerrit-ui/app/utils/lit-util.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from 'lit';
-
-/**
- * This is a patched version of html`` to work around this Chrome bug:
- * https://bugs.chromium.org/p/v8/issues/detail?id=13190.
- *
- * The problem is that Chrome should guarantee that the TemplateStringsArray
- * is always the same instance, if the strings themselves are equal, but that
- * guarantee seems to be broken. So we are maintaining a map from
- * "concatenated strings" to TemplateStringsArray. If "concatenated strings"
- * are equal, then return the already known instance of TemplateStringsArray,
- * so html`` can use its strict equality check on it.
- */
-export class HtmlPatched {
-  constructor(private readonly reporter?: (key: string) => void) {}
-
-  /**
-   * If `strings` are in this set, then we are sure that they are also in the
-   * map, and that we will not run into the issue of "same key, but different
-   * strings array". So this set allows us to optimize performance a bit, and
-   * call the native html`` function early.
-   */
-  private readonly lookupSet = new Set<TemplateStringsArray>();
-
-  private readonly lookupMap = new Map<string, TemplateStringsArray>();
-
-  /**
-   * Proxies lit's html`` tagges template literal. See
-   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
-   * https://lit.dev/docs/libraries/standalone-templates/
-   *
-   * Example: If you call html`a${1}b${2}c`, then
-   * ['a', 'b', 'c'] are the "strings", and 1, 2 are the "values".
-   */
-  html(strings: TemplateStringsArray, ...values: unknown[]) {
-    if (this.lookupSet.has(strings)) {
-      return this.nativeHtml(strings, ...values);
-    }
-
-    const key = strings.join('\0');
-    const oldStrings = this.lookupMap.get(key);
-
-    if (oldStrings === undefined) {
-      this.lookupSet.add(strings);
-      this.lookupMap.set(key, strings);
-      return this.nativeHtml(strings, ...values);
-    }
-
-    if (oldStrings === strings) {
-      return this.nativeHtml(strings, ...values);
-    }
-
-    // Without using HtmlPatcher html`` would be called with `strings`,
-    // which will be considered different, although actually being equal.
-    console.warn(`HtmlPatcher was required for '${key.substring(0, 100)}'.`);
-    this.reporter?.(key);
-    return this.nativeHtml(oldStrings, ...values);
-  }
-
-  // Allows spying on calls in tests.
-  nativeHtml(strings: TemplateStringsArray, ...values: unknown[]) {
-    return html(strings, ...values);
-  }
-}
diff --git a/polygerrit-ui/app/utils/lit-util_test.ts b/polygerrit-ui/app/utils/lit-util_test.ts
deleted file mode 100644
index 17197f0..0000000
--- a/polygerrit-ui/app/utils/lit-util_test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import '../test/common-test-setup';
-import {HtmlPatched} from './lit-util';
-
-function tsa(strings: string[]): TemplateStringsArray {
-  return strings as unknown as TemplateStringsArray;
-}
-
-suite('lit-util HtmlPatched tests', () => {
-  let patched: HtmlPatched;
-  let nativeHtmlSpy: sinon.SinonSpy;
-  let reporterSpy: sinon.SinonSpy;
-
-  setup(async () => {
-    reporterSpy = sinon.spy();
-    patched = new HtmlPatched(reporterSpy);
-    nativeHtmlSpy = sinon.spy(patched, 'nativeHtml');
-  });
-
-  test('simple call', () => {
-    const instance1 = tsa(['1']);
-    patched.html(instance1, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 1);
-    assert.equal(reporterSpy.callCount, 0);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[0], instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[1], 'a value');
-  });
-
-  test('two calls, same instance', () => {
-    const instance1 = tsa(['1']);
-    patched.html(instance1, 'a value');
-    patched.html(instance1, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 2);
-    assert.equal(reporterSpy.callCount, 0);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
-  });
-
-  test('two calls, different strings', () => {
-    const instance1 = tsa(['1']);
-    const instance2 = tsa(['2']);
-    patched.html(instance1, 'a value');
-    patched.html(instance2, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 2);
-    assert.equal(reporterSpy.callCount, 0);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance2);
-  });
-
-  test('two calls, same strings, different instances', () => {
-    const instance1 = tsa(['1']);
-    const instance2 = tsa(['1']);
-    patched.html(instance1, 'a value');
-    patched.html(instance2, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 2);
-    assert.equal(reporterSpy.callCount, 1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
-  });
-
-  test('many calls', () => {
-    const instance1a = tsa(['1']);
-    const instance1b = tsa(['1']);
-    const instance1c = tsa(['1']);
-    const instance2a = tsa(['asdf', 'qwer']);
-    const instance2b = tsa(['asdf', 'qwer']);
-    const instance2c = tsa(['asdf', 'qwer']);
-    const instance3a = tsa(['asd', 'fqwer']);
-    const instance3b = tsa(['asd', 'fqwer']);
-    const instance3c = tsa(['asd', 'fqwer']);
-
-    patched.html(instance1a, 'a value');
-    patched.html(instance1a, 'a value');
-    patched.html(instance1b, 'a value');
-    patched.html(instance1b, 'a value');
-    patched.html(instance1c, 'a value');
-    patched.html(instance1c, 'a value');
-    patched.html(instance2a, 'a value');
-    patched.html(instance2a, 'a value');
-    patched.html(instance2b, 'a value');
-    patched.html(instance2b, 'a value');
-    patched.html(instance2c, 'a value');
-    patched.html(instance2c, 'a value');
-    patched.html(instance3a, 'a value');
-    patched.html(instance3a, 'a value');
-    patched.html(instance3b, 'a value');
-    patched.html(instance3b, 'a value');
-    patched.html(instance3c, 'a value');
-    patched.html(instance3c, 'a value');
-
-    assert.equal(nativeHtmlSpy.callCount, 18);
-    assert.equal(reporterSpy.callCount, 12);
-
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[2].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[3].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[4].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[5].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[6].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[7].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[8].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[9].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[10].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[11].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[12].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[13].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[14].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[15].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[16].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[17].firstArg, instance3a);
-  });
-});