Merge "Improve documentation of withDiffContaining option for file SR operator"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 9a40b27..31c3536 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -782,7 +782,7 @@
 === Rebase
 
 This category permits users to rebase changes via the web UI by pushing
-the `Rebase Change` button.
+the `REBASE` button.
 
 The change owner and submitters can always rebase changes in the web UI
 (even without having the `Rebase` access right assigned).
@@ -1370,10 +1370,11 @@
 [[capability_createProject]]
 === Create Project
 
-Allow project creation.  This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
 
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
 
 [[capability_emailReviewers]]
 === Email Reviewers
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 2f144c6..46724e5 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6816,8 +6816,35 @@
 If true the action is permitted at this time and the caller is
 likely allowed to execute it. This may change if state is updated
 at the server or permissions are modified. Not present if false.
+|`enabled_options`      |optional|
+Optional list of enabled options. +
+See the list of suppported options link:action-options[below].
 |====================================
 
+[[action-options]]
+==== Action Options
+
+Options that are returned via the `enabled_options` field of
+link:#action-info[ActionInfo].
+
+[options="header",cols="1,^1,5"]
+|===============================
+|REST view |Option  |Description
+|`rebase`  |`rebase`|
+Present if the user can rebase the change. +
+This is the case for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission if they
+have the link:access-control.html#category_push[Push] permission.
+|`rebase`  |`rebase_on_behalf_of`|
+Present if the user can rebase the change on behalf of the uploader. +
+This is the case for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission.
+|===============================
+
+For all other REST views no options are returned.
+
 [[applypatch-input]]
 === ApplyPatchInput
 The `ApplyPatchInput` entity contains information about a patch to apply.
@@ -7278,7 +7305,7 @@
 |`real_author`         |optional|
 Real author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
-Set if the message was posted on behalf of another user.
+Only set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
 |`message`            ||
@@ -8219,22 +8246,34 @@
 The `RebaseInput` entity contains information for changing parent when rebasing.
 
 [options="header",cols="1,^1,5"]
-|===========================
-|Field Name          ||Description
-|`base`              |optional|
+|====================================
+|Field Name             ||Description
+|`base`                 |optional|
 The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
-|`allow_conflicts`   |optional, defaults to false|
+|`allow_conflicts`      |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
 git conflict markers to indicate the conflicts. +
 Callers can find out whether there were conflicts by checking the
 `contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
-If there are conflicts the change is marked as work-in-progress.
-|`validation_options`|optional|
+If there are conflicts the change is marked as work-in-progress. +
+Cannot be combined with the `on_behalf_of_uploader` option.
+|`on_behalf_of_uploader`|optional, defaults to false|
+If `true`, the rebase is done on behalf of the uploader. +
+This means the uploader of the current patch set will also be the uploader of
+the rebased patch set. The calling user will be recorded as the real user. +
+Rebasing on behalf of the uploader is only supported for trivial rebases.
+This means this option cannot be combined with the `allow_conflicts` option. +
+In addition, rebasing on behalf of the uploader is only supported for the
+current patch set of a change and not when rebasing a chain. +
+Using this option is not supported when rebasing a chain via the
+link:#rebase-chain[Rebase Chain] REST endpoint. +
+If the caller is the uploader this flag is ignored and a normal rebase is done.
+|`validation_options`   |optional|
 Map with key-value pairs that are forwarded as options to the commit validation
 listeners (e.g. can be used to skip certain validations). Which validation
 options are supported depends on the installed commit validation listeners.
@@ -8242,7 +8281,7 @@
 listeners that are implemented in plugins may. Please refer to the
 documentation of the installed plugins to learn whether they support validation
 options. Unknown validation options are silently ignored.
-|===========================
+|====================================
 
 [[rebase-chain-info]]
 === RebaseChainInfo
@@ -8623,6 +8662,10 @@
 |`uploader`    ||
 The uploader of the patch set as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`real_uploader`|optional|
+The real uploader of the patch set as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Only set if the upload was done on behalf of another user.
 |`ref`         ||The Git reference for the patch set.
 |`fetch`       ||
 Information about how to fetch this patch set. The fetch information is
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 73668d7..780d3ec 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -88,7 +88,7 @@
 
 image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
 
-- [[owner]]Owner/Uploader/Author/Committer
+- [[owner]]Owner/Uploader/Author/Committer:
 +
 Owner is the person who created the change
 +
@@ -170,11 +170,11 @@
 The SHA of the commit corresponding to the merged change on the destination
 branch.
 
-- [[revert-created-as]]Revert (Created|Submitted) As
+- [[revert-created-as]]Revert (Created|Submitted) As:
 +
 Points to the revert change, if one was created.
 
-- [[cherry-pick-of]]Cherry-pick of
+- [[cherry-pick-of]]Cherry-pick of:
 +
 If the change was created as cherry-pick of some other change to a different
 branch, points to the original change.
@@ -207,7 +207,7 @@
 link:config-submit-requirements.html[Submit Requirement Configuration] page.
 
 [[actions]]
-=== Actions:
+=== Actions
 Actions buttons are at the top right and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 565c491..e12c27c 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -43,6 +43,17 @@
 For more predictable results, use explicit search operators as described
 in the following section.
 
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
 [[search-operators]]
 == Search Operators
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index ccf74d1..a4f973e 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1754,6 +1754,14 @@
         .collect(ImmutableMap.toImmutableMap(branch -> branch.ref, branch -> branch));
   }
 
+  protected void createRefLogFileIfMissing(Repository repo, String ref) throws IOException {
+    File log = new File(repo.getDirectory(), "logs/" + ref);
+    if (!log.exists()) {
+      log.getParentFile().mkdirs();
+      assertThat(log.createNewFile()).isTrue();
+    }
+  }
+
   protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass)
       throws Exception {
     return installPlugin(pluginName, sysModuleClass, null, null);
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index c1029be..62ad7c4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -139,9 +140,10 @@
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
       Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
+      PersonIdent author = getAuthorIdent(now, changeCreation);
+      PersonIdent committer = getCommitterIdent(now, changeCreation);
       ObjectId commitId =
-          createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+          createCommit(repository, revWalk, objectInserter, changeCreation, author, committer);
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
@@ -189,6 +191,30 @@
     return getArbitraryUser();
   }
 
+  private PersonIdent getAuthorIdent(Instant when, TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.authorIdent().isPresent()) {
+      return new PersonIdent(changeCreation.authorIdent().get(), when);
+    }
+
+    return (changeCreation.author().isPresent()
+            ? userFactory.create(changeCreation.author().get())
+            : getChangeOwner(changeCreation))
+        .newCommitterIdent(when, serverIdent.getZoneId());
+  }
+
+  private PersonIdent getCommitterIdent(Instant when, TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.committerIdent().isPresent()) {
+      return new PersonIdent(changeCreation.committerIdent().get(), when);
+    }
+
+    return (changeCreation.committer().isPresent()
+            ? userFactory.create(changeCreation.committer().get())
+            : getChangeOwner(changeCreation))
+        .newCommitterIdent(when, serverIdent.getZoneId());
+  }
+
   private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
     ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
     checkState(
@@ -202,15 +228,15 @@
       RevWalk revWalk,
       ObjectInserter objectInserter,
       TestChangeCreation changeCreation,
-      PersonIdent authorAndCommitter)
+      PersonIdent author,
+      PersonIdent committer)
       throws IOException, BadRequestException {
     ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
     TreeCreator treeCreator =
         getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
     ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
     String commitMessage = correctCommitMessage(changeCreation.commitMessage());
-    return createCommit(
-        objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
+    return createCommit(objectInserter, tree, parentCommits, author, committer, commitMessage);
   }
 
   private ImmutableList<ObjectId> getParentCommits(
@@ -432,17 +458,28 @@
           ObjectInserter objectInserter = repository.newObjectInserter();
           RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
         Instant now = TimeUtil.now();
+        PersonIdent authorIdent = getAuthorIdent(now, patchsetCreation);
+        PersonIdent committerIdent = getCommitterIdent(now, patchsetCreation);
         ObjectId newPatchsetCommit =
             createPatchsetCommit(
-                repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+                repository,
+                revWalk,
+                objectInserter,
+                changeNotes,
+                patchsetCreation,
+                authorIdent,
+                committerIdent,
+                now);
 
         PatchSet.Id patchsetId =
             ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
         PatchSetInserter patchSetInserter =
             getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
 
-        IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
-        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+        Account.Id uploaderId =
+            patchsetCreation.uploader().orElse(changeNotes.getChange().getOwner());
+        IdentifiedUser uploader = userFactory.create(uploaderId);
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, uploader, now)) {
           batchUpdate.setRepository(repository, revWalk, objectInserter);
           batchUpdate.addOp(changeId, patchSetInserter);
           batchUpdate.execute();
@@ -451,12 +488,44 @@
       }
     }
 
+    @Nullable
+    private PersonIdent getAuthorIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+      if (patchsetCreation.authorIdent().isPresent()) {
+        return new PersonIdent(patchsetCreation.authorIdent().get(), when);
+      }
+
+      if (patchsetCreation.author().isPresent()) {
+        return userFactory
+            .create(patchsetCreation.author().get())
+            .newCommitterIdent(when, serverIdent.getZoneId());
+      }
+
+      return null;
+    }
+
+    @Nullable
+    private PersonIdent getCommitterIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+      if (patchsetCreation.committerIdent().isPresent()) {
+        return new PersonIdent(patchsetCreation.committerIdent().get(), when);
+      }
+
+      if (patchsetCreation.committer().isPresent()) {
+        return userFactory
+            .create(patchsetCreation.committer().get())
+            .newCommitterIdent(when, serverIdent.getZoneId());
+      }
+
+      return null;
+    }
+
     private ObjectId createPatchsetCommit(
         Repository repository,
         RevWalk revWalk,
         ObjectInserter objectInserter,
         ChangeNotes changeNotes,
         TestPatchsetCreation patchsetCreation,
+        @Nullable PersonIdent author,
+        @Nullable PersonIdent committer,
         Instant now)
         throws IOException, BadRequestException {
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
@@ -472,9 +541,13 @@
               changeNotes.getChange().getKey().get(),
               patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
 
-      PersonIdent author = getAuthor(oldPatchsetCommit);
-      PersonIdent committer = getCommitter(oldPatchsetCommit, now);
-      return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+      return createCommit(
+          objectInserter,
+          tree,
+          parentCommitIds,
+          Optional.ofNullable(author).orElse(getAuthor(oldPatchsetCommit)),
+          Optional.ofNullable(committer).orElse(getCommitter(oldPatchsetCommit, now)),
+          commitMessage);
     }
 
     private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index f01a138..a0746e2 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -24,6 +26,7 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.merge.MergeStrategy;
 
 /** Initial attributes of the change. If not provided, arbitrary values will be used. */
@@ -35,6 +38,14 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<PersonIdent> authorIdent();
+
+  public abstract Optional<Account.Id> committer();
+
+  public abstract Optional<PersonIdent> committerIdent();
+
   public abstract Optional<String> topic();
 
   public abstract ImmutableMap<String, Short> approvals();
@@ -69,9 +80,65 @@
      */
     public abstract Builder branch(String branch);
 
-    /** The change owner. Must be an existing user account. */
+    /**
+     * The change owner.
+     *
+     * <p>Must be an existing user account.
+     */
     public abstract Builder owner(Account.Id owner);
 
+    /**
+     * The author of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #authorIdent()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder author(Account.Id author);
+
+    /**
+     * The author ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #author()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder authorIdent(PersonIdent authorIdent);
+
+    public abstract Optional<Account.Id> author();
+
+    public abstract Optional<PersonIdent> authorIdent();
+
+    /**
+     * The committer of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #committerIdent()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committer(Account.Id committer);
+
+    /**
+     * The committer ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #committer()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committerIdent(PersonIdent committerIdent);
+
+    public abstract Optional<Account.Id> committer();
+
+    public abstract Optional<PersonIdent> committerIdent();
+
     /** The topic to add this change to. */
     public abstract Builder topic(String topic);
 
@@ -156,13 +223,23 @@
 
     abstract TestChangeCreation autoBuild();
 
+    public TestChangeCreation build() {
+      checkState(
+          author().isEmpty() || authorIdent().isEmpty(),
+          "author and authorIdent cannot be set together");
+      checkState(
+          committer().isEmpty() || committerIdent().isEmpty(),
+          "committer and committerIdent cannot be set together");
+      return autoBuild();
+    }
+
     /**
      * Creates the change.
      *
      * @return the {@code Change.Id} of the created change
      */
     public Change.Id create() {
-      TestChangeCreation changeUpdate = autoBuild();
+      TestChangeCreation changeUpdate = build();
       return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
     }
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index 22a4da6..f8ca977 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -14,17 +14,31 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
 @AutoValue
 public abstract class TestPatchsetCreation {
 
+  public abstract Optional<Account.Id> uploader();
+
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<PersonIdent> authorIdent();
+
+  public abstract Optional<Account.Id> committer();
+
+  public abstract Optional<PersonIdent> committerIdent();
+
   public abstract Optional<String> commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -40,6 +54,66 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    /**
+     * The uploader for the new patch set.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>If not set the new patch set is uploaded by the change owner.
+     */
+    public abstract Builder uploader(Account.Id uploader);
+
+    /**
+     * The author of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #authorIdent()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder author(Account.Id author);
+
+    /**
+     * The author ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #author()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder authorIdent(PersonIdent authorIdent);
+
+    public abstract Optional<Account.Id> author();
+
+    public abstract Optional<PersonIdent> authorIdent();
+
+    /**
+     * The committer of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #committerIdent()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committer(Account.Id committer);
+
+    /**
+     * The committer ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #committer()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committerIdent(PersonIdent committerIdent);
+
+    public abstract Optional<Account.Id> committer();
+
+    public abstract Optional<PersonIdent> committerIdent();
 
     public abstract Builder commitMessage(String commitMessage);
 
@@ -92,13 +166,23 @@
 
     abstract TestPatchsetCreation autoBuild();
 
+    public TestPatchsetCreation build() {
+      checkState(
+          author().isEmpty() || authorIdent().isEmpty(),
+          "author and authorIdent cannot be set together");
+      checkState(
+          committer().isEmpty() || committerIdent().isEmpty(),
+          "committer and committerIdent cannot be set together");
+      return autoBuild();
+    }
+
     /**
      * Creates the patchset.
      *
      * @return the {@code PatchSet.Id} of the created patchset
      */
     public PatchSet.Id create() {
-      TestPatchsetCreation patchsetCreation = autoBuild();
+      TestPatchsetCreation patchsetCreation = build();
       return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
     }
   }
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 354981c..8784437 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -170,6 +170,8 @@
 
     public abstract Builder uploader(Account.Id uploader);
 
+    public abstract Builder realUploader(Account.Id realUploader);
+
     public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder groups(Iterable<String> groups);
@@ -204,6 +206,9 @@
   /**
    * Account that uploaded the patch set.
    *
+   * <p>If the upload was done on behalf of another user, the impersonated user on whom's behalf the
+   * patch set was uploaded.
+   *
    * <p>If this is a deserialized instance that was originally serialized by an older version of
    * Gerrit, and the old data erroneously did not include an {@code uploader}, then this method will
    * return an account ID of 0.
@@ -211,6 +216,15 @@
   public abstract Account.Id uploader();
 
   /**
+   * The real account that uploaded the patch set.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data did not include an {@code realUploader}, then this method will return
+   * the {@code uploader}.
+   */
+  public abstract Account.Id realUploader();
+
+  /**
    * When this patch set was first introduced onto the change.
    *
    * <p>If this is a deserialized instance that was originally serialized by an older version of
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 210972d..b32f09a 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -42,6 +42,7 @@
             .setId(patchSetIdConverter.toProto(patchSet.id()))
             .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
+            .setRealUploaderAccountId(accountIdConverter.toProto(patchSet.realUploader()))
             .setCreatedOn(patchSet.createdOn().toEpochMilli());
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
@@ -75,15 +76,20 @@
     // Callers that encounter one of these sentinels will likely fail, for example by failing to
     // look up the zeroId. They would have also failed back when the fields were nullable, for
     // example with NPE; the current behavior just fails slightly differently.
+    Account.Id uploader =
+        proto.hasUploaderAccountId()
+            ? accountIdConverter.fromProto(proto.getUploaderAccountId())
+            : Account.id(0);
     builder
         .commitId(
             proto.hasCommitId()
                 ? objectIdConverter.fromProto(proto.getCommitId())
                 : ObjectId.zeroId())
-        .uploader(
-            proto.hasUploaderAccountId()
-                ? accountIdConverter.fromProto(proto.getUploaderAccountId())
-                : Account.id(0))
+        .uploader(uploader)
+        .realUploader(
+            proto.hasRealUploaderAccountId()
+                ? accountIdConverter.fromProto(proto.getRealUploaderAccountId())
+                : uploader)
         .createdOn(
             proto.hasCreatedOn() ? Instant.ofEpochMilli(proto.getCreatedOn()) : Instant.EPOCH);
 
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index e9b05cc..a85bc73 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -27,5 +27,21 @@
    */
   public boolean allowConflicts;
 
+  /**
+   * Whether the rebase should be done on behalf of the uploader.
+   *
+   * <p>This means the uploader of the current patch set will also be the uploader of the rebased
+   * patch set. The calling user will be recorded as the real user.
+   *
+   * <p>Rebasing on behalf of the uploader is only supported for trivial rebases. This means this
+   * option cannot be combined with the {@link #allowConflicts} option.
+   *
+   * <p>In addition, rebasing on behalf of the uploader is only supported for the current patch set
+   * of a change and not when rebasing a chain.
+   *
+   * <p>Using this option is not supported when rebasing a chain via the Rebase Chain REST endpoint.
+   */
+  public boolean onBehalfOfUploader;
+
   public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 2144ed5..f148444 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.UiAction;
+import java.util.List;
 import java.util.Objects;
 
 /**
@@ -50,11 +51,30 @@
    */
   public Boolean enabled;
 
+  /**
+   * Optional list of enabled options.
+   *
+   * <p>For the {@code rebase} REST view the following options are supported:
+   *
+   * <ul>
+   *   <li>{@code rebase}: Present if the user can rebase the change. This is the case for the
+   *       change owner and users with the {@code Submit} or {@code Rebase} permission if they have
+   *       the {@code Push} permission.
+   *   <li>{@code rebase_on_behalf_of}: Present if the user can rebase the change on behalf of the
+   *       uploader. This is the case for the change owner and users with the {@code Submit} or
+   *       {@code Rebase} permission.
+   * </ul>
+   *
+   * <p>For all other REST views no options are returned.
+   */
+  public List<String> enabledOptions;
+
   public ActionInfo(UiAction.Description d) {
     method = d.getMethod();
     label = d.getLabel();
     title = d.getTitle();
     enabled = d.isEnabled() ? true : null;
+    enabledOptions = d.getEnabledOptions();
   }
 
   @Override
@@ -64,14 +84,15 @@
       return Objects.equals(method, actionInfo.method)
           && Objects.equals(label, actionInfo.label)
           && Objects.equals(title, actionInfo.title)
-          && Objects.equals(enabled, actionInfo.enabled);
+          && Objects.equals(enabled, actionInfo.enabled)
+          && Objects.equals(enabledOptions, actionInfo.enabledOptions);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(method, label, title, enabled);
+    return Objects.hash(method, label, title, enabled, enabledOptions);
   }
 
   protected ActionInfo() {}
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 7c52c8c..941dffe 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -32,6 +32,7 @@
   public Timestamp created;
 
   public AccountInfo uploader;
+  public AccountInfo realUploader;
   public String ref;
   public Map<String, FetchInfo> fetch;
   public CommitInfo commit;
@@ -72,6 +73,7 @@
           && _number == revisionInfo._number
           && Objects.equals(created, revisionInfo.created)
           && Objects.equals(uploader, revisionInfo.uploader)
+          && Objects.equals(realUploader, revisionInfo.realUploader)
           && Objects.equals(ref, revisionInfo.ref)
           && Objects.equals(fetch, revisionInfo.fetch)
           && Objects.equals(commit, revisionInfo.commit)
@@ -92,6 +94,7 @@
         _number,
         created,
         uploader,
+        realUploader,
         ref,
         fetch,
         commit,
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index 2f21bf3..9da0642 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.extensions.webui;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import java.util.ArrayList;
+import java.util.List;
 
 public interface UiAction<R extends RestResource> extends RestView<R> {
   /**
@@ -40,6 +43,7 @@
     private String title;
     private BooleanCondition visible = BooleanCondition.TRUE;
     private BooleanCondition enabled = BooleanCondition.TRUE;
+    private List<String> enabledOptions = new ArrayList<>();
 
     public String getMethod() {
       return method;
@@ -122,5 +126,22 @@
       this.enabled = enabled;
       return this;
     }
+
+    @Nullable
+    public ImmutableList<String> getEnabledOptions() {
+      if (enabledOptions.isEmpty()) {
+        return null;
+      }
+      return ImmutableList.copyOf(enabledOptions);
+    }
+
+    public Description setOption(String optionName, boolean enabled) {
+      if (enabled) {
+        enabledOptions.add(optionName);
+      } else {
+        enabledOptions.remove(optionName);
+      }
+      return this;
+    }
   }
 }
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index df2c5cb..7293f35 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -310,6 +311,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
     modules.add(new PluginApiModule());
     modules.add(new SearchingChangeCacheImplModule());
     modules.add(new InternalAccountDirectoryModule());
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 129d961..961bf9b 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -79,7 +79,6 @@
           "/dashboard/*",
           "/groups/self",
           "/settings/*",
-          "/topic/*",
           "/Documentation/q/*");
 
   /**
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index f2aafcf9..870d827 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -156,4 +156,14 @@
   default boolean isEnabled() {
     return true;
   }
+
+  /**
+   * Rewriter that should be invoked on queries to this index.
+   *
+   * <p>The default implementation does not do anything. Should be overridden by implementation, if
+   * needed.
+   */
+  default IndexRewriter<V> getIndexRewriter() {
+    return (in, opts) -> in;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index f237006..1c8bbc3 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -268,7 +268,9 @@
                 limit,
                 getRequestedFields());
         logger.atFine().log("Query options: %s", opts);
-        Predicate<T> pred = rewriter.rewrite(q, opts);
+        // Apply index-specific rewrite first
+        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+        pred = rewriter.rewrite(pred, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 0342fe5..845cc9a 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -448,6 +449,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
     modules.add(new PluginApiModule());
 
     modules.add(new SearchingChangeCacheImplModule(replica));
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 5bffce7..cae7ca6 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.LibModuleLoader;
@@ -83,19 +84,19 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
-import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -127,6 +128,7 @@
     modules.add(PatchListCacheImpl.module());
     modules.add(new DefaultUrlFormatterModule());
     modules.add(DiffOperationsImpl.module());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 81cff6e..c84c0e7 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -150,7 +150,7 @@
     cmi.tag = message.getTag();
     cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
     Account.Id realAuthor = message.getRealAuthor();
-    if (realAuthor != null) {
+    if (realAuthor != null && !realAuthor.equals(message.getAuthor())) {
       cmi.realAuthor = accountLoader.get(realAuthor);
     }
     cmi.accountsInMessage =
diff --git a/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
new file mode 100644
index 0000000..bef276a
--- /dev/null
+++ b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@Singleton
+public class DefaultRefLogIdentityProvider implements RefLogIdentityProvider {
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(RefLogIdentityProvider.class).to(DefaultRefLogIdentityProvider.class);
+    }
+  }
+
+  private final String anonymousCowardName;
+  private final Boolean enablePeerIPInReflogRecord;
+
+  @Inject
+  DefaultRefLogIdentityProvider(
+      @AnonymousCowardName String anonymousCowardName,
+      @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord) {
+    this.anonymousCowardName = anonymousCowardName;
+    this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
+  }
+
+  @Override
+  public PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId) {
+    Account account = user.getAccount();
+
+    String name = account.fullName();
+    if (name == null || name.isEmpty()) {
+      name = account.preferredEmail();
+    }
+    if (name == null || name.isEmpty()) {
+      name = anonymousCowardName;
+    }
+
+    String email;
+    if (enablePeerIPInReflogRecord) {
+      email = constructMailAddress(user, guessHost(user));
+    } else {
+      email =
+          Strings.isNullOrEmpty(account.preferredEmail())
+              ? constructMailAddress(user, "unknown")
+              : account.preferredEmail();
+    }
+
+    return new PersonIdent(name, email, when, zoneId);
+  }
+
+  private String constructMailAddress(IdentifiedUser user, String host) {
+    return user.getUserName().orElse("")
+        + "|account-"
+        + user.getAccountId().toString()
+        + "@"
+        + host;
+  }
+
+  private String guessHost(IdentifiedUser user) {
+    String host = null;
+    SocketAddress remotePeer = user.getRemotePeer();
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? in.getHostAddress() : sa.getHostName();
+    }
+    if (Strings.isNullOrEmpty(host)) {
+      return "unknown";
+    }
+    return host;
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 65a81f7..36d7888 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -19,7 +19,6 @@
 import static com.google.common.flogger.LazyArgs.lazy;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -44,8 +43,6 @@
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
@@ -66,6 +63,7 @@
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
+    private final RefLogIdentityProvider refLogIdentityProvider;
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
@@ -76,6 +74,7 @@
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
+        RefLogIdentityProvider refLogIdentityProvider,
         @CanonicalWebUrl Provider<String> canonicalUrl,
         @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         AccountCache accountCache,
@@ -83,6 +82,7 @@
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
+      this.refLogIdentityProvider = refLogIdentityProvider;
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
@@ -94,36 +94,37 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
           enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
-          null);
+          /* realUser= */ null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create(null, id);
+      return create(/* remotePeer= */ null, id);
     }
 
     @VisibleForTesting
     @UsedAt(UsedAt.Project.GOOGLE)
     public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
-      return runAs(null, id, null, properties);
+      return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
     }
 
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
+    public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, /* caller= */ null);
     }
 
     public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+        @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
     }
 
     private IdentifiedUser runAs(
-        SocketAddress remotePeer,
+        @Nullable SocketAddress remotePeer,
         Account.Id id,
         @Nullable CurrentUser caller,
         PropertyMap properties) {
@@ -131,6 +132,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -153,6 +155,7 @@
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
+    private final RefLogIdentityProvider refLogIdentityProvider;
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
@@ -164,6 +167,7 @@
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
+        RefLogIdentityProvider refLogIdentityProvider,
         @CanonicalWebUrl Provider<String> canonicalUrl,
         AccountCache accountCache,
         GroupBackend groupBackend,
@@ -172,6 +176,7 @@
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
+      this.refLogIdentityProvider = refLogIdentityProvider;
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
@@ -188,6 +193,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -203,6 +209,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -224,6 +231,7 @@
   private final Realm realm;
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
+  private final RefLogIdentityProvider refLogIdentityProvider;
   private final Boolean enablePeerIPInReflogRecord;
   private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
   private final CurrentUser realUser; // Must be final since cached properties depend on it.
@@ -235,22 +243,25 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
+  private PersonIdent refLogIdent;
 
   private IdentifiedUser(
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
         authConfig,
         realm,
         anonymousCowardName,
+        refLogIdentityProvider,
         canonicalUrl,
         accountCache,
         groupBackend,
@@ -266,11 +277,12 @@
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser,
       PropertyMap properties) {
@@ -281,6 +293,7 @@
     this.authConfig = authConfig;
     this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
+    this.refLogIdentityProvider = refLogIdentityProvider;
     this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     this.remotePeerProvider = remotePeerProvider;
     this.accountId = id;
@@ -426,36 +439,27 @@
     return getAccountId();
   }
 
+  @Nullable
+  public SocketAddress getRemotePeer() {
+    try {
+      return remotePeerProvider.get();
+    } catch (OutOfScopeException | ProvisionException e) {
+      return null;
+    }
+  }
+
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
+    return refLogIdentityProvider.newRefLogIdent(this);
   }
 
   public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
-    final Account ua = getAccount();
-
-    String name = ua.fullName();
-    if (name == null || name.isEmpty()) {
-      name = ua.preferredEmail();
+    if (refLogIdent != null) {
+      refLogIdent =
+          new PersonIdent(refLogIdent.getName(), refLogIdent.getEmailAddress(), when, zoneId);
+      return refLogIdent;
     }
-    if (name == null || name.isEmpty()) {
-      name = anonymousCowardName;
-    }
-
-    String user;
-    if (enablePeerIPInReflogRecord) {
-      user = constructMailAddress(ua, guessHost());
-    } else {
-      user =
-          Strings.isNullOrEmpty(ua.preferredEmail())
-              ? constructMailAddress(ua, "unknown")
-              : ua.preferredEmail();
-    }
-
-    return new PersonIdent(name, user, when, zoneId);
-  }
-
-  private String constructMailAddress(Account ua, String host) {
-    return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
+    refLogIdent = refLogIdentityProvider.newRefLogIdent(this, when, zoneId);
+    return refLogIdent;
   }
 
   public PersonIdent newCommitterIdent(PersonIdent ident) {
@@ -533,6 +537,7 @@
         authConfig,
         realm,
         anonymousCowardName,
+        refLogIdentityProvider,
         Providers.of(canonicalUrl.get()),
         accountCache,
         groupBackend,
@@ -546,23 +551,4 @@
   public boolean hasSameAccountId(CurrentUser other) {
     return getAccountId().get() == other.getAccountId().get();
   }
-
-  private String guessHost() {
-    String host = null;
-    SocketAddress remotePeer = null;
-    try {
-      remotePeer = remotePeerProvider.get();
-    } catch (OutOfScopeException | ProvisionException e) {
-      // Leave null.
-    }
-    if (remotePeer instanceof InetSocketAddress) {
-      InetSocketAddress sa = (InetSocketAddress) remotePeer;
-      InetAddress in = sa.getAddress();
-      host = in != null ? in.getHostAddress() : sa.getHostName();
-    }
-    if (Strings.isNullOrEmpty(host)) {
-      return "unknown";
-    }
-    return host;
-  }
 }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 2962108..b34a56f 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -107,6 +107,7 @@
         .id(psId)
         .commitId(commit)
         .uploader(update.getAccountId())
+        .realUploader(update.getRealAccountId())
         .createdOn(update.getWhen())
         .groups(groups)
         .pushCertificate(Optional.ofNullable(pushCertificate))
diff --git a/java/com/google/gerrit/server/RefLogIdentityProvider.java b/java/com/google/gerrit/server/RefLogIdentityProvider.java
new file mode 100644
index 0000000..2a5d2b0
--- /dev/null
+++ b/java/com/google/gerrit/server/RefLogIdentityProvider.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Extension point that allows to control which identity should be recorded in the reflog for ref
+ * updates done by a user or done on behalf of a user.
+ */
+public interface RefLogIdentityProvider {
+  /**
+   * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+   * the reflog for ref updates done by this user or done on behalf of this user.
+   *
+   * <p>The returned {@link PersonIdent} is created with the current timestamp and the system
+   * default timezone.
+   *
+   * @param user the user for which a reflog identity should be created
+   */
+  default PersonIdent newRefLogIdent(IdentifiedUser user) {
+    return newRefLogIdent(user, Instant.now(), ZoneId.systemDefault());
+  }
+
+  /**
+   * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+   * the reflog for ref updates done by this user or done on behalf of this user.
+   *
+   * @param user the user for which a reflog identity should be created
+   * @param when the timestamp that should be used to create the {@link PersonIdent}
+   * @param zoneId the zone ID identifying the timezone that should be used to create the {@link
+   *     PersonIdent}
+   */
+  PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId);
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 8f413f9..9403105 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -266,7 +266,7 @@
     }
   }
 
-  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, String label) {
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
@@ -277,7 +277,7 @@
         }
         // Skip all refs that don't contain the required label.
         StarRef starRef = readLabels(repo, ref.getName());
-        if (!starRef.labels().contains(label)) {
+        if (!starRef.labels().contains(DEFAULT_LABEL)) {
           continue;
         }
 
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 389b292..d2417d7 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -211,7 +211,7 @@
         return searchedAsUser.asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+          /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -790,7 +790,7 @@
 
     @Override
     public Predicate<AccountState> get() {
-      return accountControlFactory.get(asUser.asIdentifiedUser())::canSee;
+      return accountControlFactory.get(asUser)::canSee;
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index ed6d53d..a9d5959 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -166,6 +166,7 @@
     copy.ref = revisionInfo.ref;
     copy.created = revisionInfo.created;
     copy.uploader = revisionInfo.uploader;
+    copy.realUploader = revisionInfo.realUploader;
     copy.fetch = revisionInfo.fetch;
     copy.kind = revisionInfo.kind;
     copy.description = revisionInfo.description;
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 49ec812..ed87c76 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -326,7 +327,8 @@
 
     if (postMessage) {
       patchSetInserter.setMessage(
-          messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
+          messageForRebasedChange(
+              ctx.getIdentifiedUser(), rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
@@ -344,13 +346,22 @@
   }
 
   private static String messageForRebasedChange(
-      PatchSet.Id rebasePatchSetId, PatchSet.Id originalPatchSetId, CodeReviewCommit commit) {
+      IdentifiedUser user,
+      PatchSet.Id rebasePatchSetId,
+      PatchSet.Id originalPatchSetId,
+      CodeReviewCommit commit) {
     StringBuilder stringBuilder =
         new StringBuilder(
             String.format(
                 "Patch Set %d: Patch Set %d was rebased",
                 rebasePatchSetId.get(), originalPatchSetId.get()));
 
+    if (user.isImpersonating()) {
+      stringBuilder.append(
+          String.format(
+              " on behalf of %s", AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
+    }
+
     if (!commit.getFilesWithGitConflicts().isEmpty()) {
       stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
       commit.getFilesWithGitConflicts().stream()
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index dcbd1ae..8acc925 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -37,7 +35,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -70,30 +67,28 @@
     this.rebaseFactory = rebaseFactory;
   }
 
-  public static void verifyRebasePreconditions(
-      ProjectCache projectCache, PatchSetUtil patchSetUtil, RevWalk rw, RevisionResource rsrc)
-      throws ResourceConflictException, IOException, AuthException, PermissionBackendException {
+  /**
+   * 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.
+   */
+  public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+      throws ResourceConflictException, IOException {
     // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
+    psUtil.checkPatchSetNotLocked(changeNotes);
 
-    rsrc.permissions().check(ChangePermission.REBASE);
-    projectCache
-        .get(rsrc.getProject())
-        .orElseThrow(illegalState(rsrc.getProject()))
-        .checkStatePermitsWrite();
-
-    if (!rsrc.getChange().isNew()) {
+    Change change = changeNotes.getChange();
+    if (!change.isNew()) {
       throw new ResourceConflictException(
-          String.format(
-              "Change %s is %s", rsrc.getChange().getId(), ChangeUtil.status(rsrc.getChange())));
-    } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
+          String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+    }
+
+    if (!hasOneParent(rw, patchSet)) {
       throw new ResourceConflictException(
           String.format(
               "Error rebasing %s. Cannot rebase %s",
-              rsrc.getChange().getId(),
-              countParents(rw, rsrc.getPatchSet()) > 1
-                  ? "merge commits"
-                  : "commit with no ancestor"));
+              change.getId(),
+              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 5469b51..ce63c7e 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -288,6 +288,9 @@
     out.ref = in.refName();
     out.setCreated(in.createdOn());
     out.uploader = accountLoader.get(in.uploader());
+    if (!in.uploader().equals(in.realUploader())) {
+      out.realUploader = accountLoader.get(in.realUploader());
+    }
     out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.description().orElse(null);
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index 8fbb259..0df7729 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -225,6 +225,11 @@
 
   private static Optional<Range> getStartAndEndLines(ContextInput comment) {
     if (comment.range() != null) {
+      if (comment.range().endLine < comment.range().startLine) {
+        // Seems like comments, created in reply to robot comments sometimes have invalid ranges
+        // Fix here, otherwise the range is invalid and we throw an error later on.
+        return Optional.of(Range.create(comment.range().startLine, comment.range().startLine + 1));
+      }
       return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
     } else if (comment.lineNumber() > 0) {
       return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index f442500..f194434 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -194,14 +194,11 @@
 import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
-import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -217,6 +214,9 @@
 import com.google.gerrit.server.submit.MergeSuperSetComputation;
 import com.google.gerrit.server.submit.SubmitStrategy;
 import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 5a4580c..32ec401 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -20,14 +20,9 @@
 public class ExperimentFeaturesConstants {
 
   /** Features that are known experiments and can be referenced in the code. */
-  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
-
-  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
-
   public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
       "GerritBackendFeature__attach_nonce_to_documentation";
 
   /** Features, enabled by default in the current release. */
-  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 1c89566f..2045dba 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -227,8 +227,7 @@
 
   /** List of full file paths modified in the current patch set. */
   public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
-      // Named for backwards compatibility.
-      IndexedField.<ChangeData>iterableStringBuilder("File")
+      IndexedField.<ChangeData>iterableStringBuilder("ModifiedFile")
           .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
 
   public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
@@ -770,7 +769,7 @@
 
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final IndexedField<ChangeData, Iterable<String>> COMMIT_FIELD =
-      IndexedField.<ChangeData>iterableStringBuilder("Commit")
+      IndexedField.<ChangeData>iterableStringBuilder("CommitId")
           .size(40)
           .required()
           .build(ChangeField::getRevisions);
@@ -1222,7 +1221,7 @@
 
   /** Determines if this change is private. */
   public static final IndexedField<ChangeData, String> PRIVATE_FIELD =
-      IndexedField.<ChangeData>stringBuilder("Private")
+      IndexedField.<ChangeData>stringBuilder("IsPrivate")
           .size(1)
           .build(cd -> cd.change().isPrivate() ? "1" : "0");
 
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 8ee8fc2..9d75abd 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.io.CharStreams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -24,13 +26,13 @@
 import com.google.inject.Singleton;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.jbcsrc.api.SoySauce;
-import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import org.eclipse.jgit.util.FileUtils;
 
 /**
  * Configures and loads Soy Sauce object for rendering email templates.
@@ -90,56 +92,76 @@
     "SetAssigneeHtml.soy",
   };
 
+  private static final SoySauce DEFAULT = getDefault().build().compileTemplates();
+
   private final SitePaths site;
-  private final SoyAstCache cache;
   private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
 
   @Inject
-  MailSoySauceLoader(
-      SitePaths site,
-      SoyAstCache cache,
-      PluginSetContext<MailSoyTemplateProvider> templateProviders) {
+  MailSoySauceLoader(SitePaths site, PluginSetContext<MailSoyTemplateProvider> templateProviders) {
     this.site = site;
-    this.cache = cache;
     this.templateProviders = templateProviders;
   }
 
   public SoySauce load() {
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    builder.setSoyAstCache(cache);
-    for (String name : TEMPLATES) {
-      addTemplate(builder, "com/google/gerrit/server/mail/", name);
+    if (!hasCustomTemplates(site, templateProviders)) {
+      return DEFAULT;
     }
+
+    SoyFileSet.Builder builder = getDefault();
     templateProviders.runEach(
-        e -> e.getFileNames().forEach(p -> addTemplate(builder, e.getPath(), p)));
+        e -> e.getFileNames().forEach(p -> addTemplate(builder, site, e.getPath(), p)));
     return builder.build().compileTemplates();
   }
 
-  private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
+  private static boolean hasCustomTemplates(
+      SitePaths site, PluginSetContext<MailSoyTemplateProvider> templateProviders) {
+    try {
+      if (!templateProviders.isEmpty()) {
+        return true;
+      }
+      return Files.exists(site.mail_dir) && FileUtils.hasFiles(site.mail_dir);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private static SoyFileSet.Builder getDefault() {
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    for (String name : TEMPLATES) {
+      addTemplate(builder, null, "com/google/gerrit/server/mail/", name);
+    }
+    return builder;
+  }
+
+  private static void addTemplate(
+      SoyFileSet.Builder builder, @Nullable SitePaths site, String resourcePath, String name)
       throws ProvisionException {
     if (!resourcePath.endsWith("/")) {
       resourcePath += "/";
     }
     String logicalPath = resourcePath + name;
 
-    // Load as a file in the mail templates directory if present.
-    Path tmpl = site.mail_dir.resolve(name);
-    if (Files.isRegularFile(tmpl)) {
-      String content;
-      // TODO(davido): Consider using JGit's FileSnapshot to cache based on
-      // mtime.
-      try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
-        content = CharStreams.toString(r);
-      } catch (IOException err) {
-        throw new ProvisionException(
-            "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
+    if (site != null) {
+      // Load as a file in the mail templates directory if present.
+      Path tmpl = site.mail_dir.resolve(name);
+      if (Files.isRegularFile(tmpl)) {
+        String content;
+        // TODO(davido): Consider using JGit's FileSnapshot to cache based on
+        // mtime.
+        try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
+          content = CharStreams.toString(r);
+        } catch (IOException err) {
+          throw new ProvisionException(
+              "Failed to read template file " + tmpl.toAbsolutePath(), err);
+        }
+        builder.add(content, logicalPath);
+        return;
       }
-      builder.add(content, logicalPath);
-      return;
     }
 
     // Otherwise load the template as a resource.
-    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    URL resource = MailSoySauceLoader.class.getClassLoader().getResource(logicalPath);
     checkArgument(resource != null, "resource %s not found.", logicalPath);
     builder.add(resource, logicalPath);
   }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index ba91c68..708d59f 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -164,6 +164,10 @@
     return accountId;
   }
 
+  public Account.Id getRealAccountId() {
+    return realAccountId;
+  }
+
   /** Whether no updates have been done. */
   public abstract boolean isEmpty();
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 77d1c8f..467095c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -515,7 +515,7 @@
 
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
-      parsePatchSet(psId, currRev, accountId, commitTimestamp);
+      parsePatchSet(psId, currRev, accountId, realAccountId, commitTimestamp);
     }
     parseCurrentPatchSet(psId, commit);
 
@@ -648,7 +648,8 @@
     }
   }
 
-  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
+  private void parsePatchSet(
+      PatchSet.Id psId, ObjectId rev, Account.Id accountId, Account.Id realAccountId, Instant ts)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
@@ -665,6 +666,7 @@
         .id(psId)
         .commitId(rev)
         .uploader(accountId)
+        .realUploader(realAccountId)
         .createdOn(ts);
     // Fields not set here:
     // * Groups, parsed earlier in parseGroups.
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 6f7d761..b963361 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -84,6 +84,19 @@
         && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
+  /**
+   * Can this user rebase this change on behalf of the uploader?
+   *
+   * <p>This only checks the permissions of the rebaser (aka the impersonating user).
+   *
+   * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+   * user) to have permissions to create the new patch set. These permissions need to be checked
+   * separately.
+   */
+  private boolean canRebaseOnBehalfOfUploader() {
+    return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase());
+  }
+
   /** Can this user restore this change? */
   private boolean canRestore() {
     // Anyone who can abandon the change can restore it, as long as they can create changes.
@@ -216,7 +229,10 @@
     public void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
+        throw new AuthException(
+            perm.describeForException()
+                + " not permitted"
+                + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
       }
     }
 
@@ -269,6 +285,8 @@
             return canEditTopicName();
           case REBASE:
             return canRebase();
+          case REBASE_ON_BEHALF_OF_UPLOADER:
+            return canRebaseOnBehalfOfUploader();
           case RESTORE:
             return canRestore();
           case REVERT:
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 63b0378..c456bf8 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -16,7 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
 public enum ChangePermission implements ChangePermissionOrLabel {
   READ,
@@ -53,24 +55,53 @@
    * <p>Before checking this permission, the caller should first verify the current patch set of the
    * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
    */
-  REBASE,
+  REBASE(
+      /* description= */ null,
+      /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase"
+          + " if they have the 'Push' permission"),
+  /**
+   * Permission that is required for a user to rebase a change on behalf of the uploader.
+   *
+   * <p>This only covers the permissions of the rebaser (aka the impersonating user).
+   *
+   * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+   * user) to have permissions to create the new patch set. These permissions need to be checked
+   * separately.
+   */
+  REBASE_ON_BEHALF_OF_UPLOADER(
+      /* description= */ null,
+      /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase on"
+          + " behalf of the uploader"),
   REVERT,
   SUBMIT,
   SUBMIT_AS("submit on behalf of other users"),
   TOGGLE_WORK_IN_PROGRESS_STATE;
 
   private final String description;
+  private final String hint;
 
   ChangePermission() {
     this.description = null;
+    this.hint = null;
   }
 
   ChangePermission(String description) {
     this.description = requireNonNull(description);
+    this.hint = null;
+  }
+
+  ChangePermission(@Nullable String description, String hint) {
+    this.description = description;
+    this.hint = requireNonNull(hint);
   }
 
   @Override
   public String describeForException() {
     return description != null ? description : GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public Optional<String> hintForException() {
+    return Optional.ofNullable(hint);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index f59ba02..9254158 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -15,6 +15,17 @@
 package com.google.gerrit.server.permissions;
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
 /** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
-public interface ChangePermissionOrLabel extends GerritPermission {}
+public interface ChangePermissionOrLabel extends GerritPermission {
+  /**
+   * A hint that explains under which conditions this permission is permitted.
+   *
+   * <p>This is useful for permissions that are not directly assigned but are indirectly permitted
+   * by the user having other permissions or being the change owner.
+   */
+  default Optional<String> hintForException() {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 0afaa3f..6498d1b 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -76,7 +76,6 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -301,20 +300,19 @@
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
-      Stream<AccountGroup.UUID> configuredRelevantGroups =
-          Arrays.stream(config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
-              .map(AccountGroup::uuid);
-
-      Stream<AccountGroup.UUID> guessedRelevantGroups =
-          inMemoryProjectCache.asMap().values().stream()
-              .filter(Objects::nonNull)
-              .flatMap(p -> p.getAllGroupUUIDs().stream())
-              // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-              // against them just in case there is a bug or corner case.
-              .filter(id -> id != null && id.get() != null);
-
       Set<AccountGroup.UUID> relevantGroupUuids =
-          Streams.concat(configuredRelevantGroups, guessedRelevantGroups).collect(toSet());
+          Streams.concat(
+                  Arrays.stream(
+                          config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
+                      .map(AccountGroup::uuid),
+                  all().stream()
+                      .map(n -> inMemoryProjectCache.getIfPresent(n))
+                      .filter(Objects::nonNull)
+                      .flatMap(p -> p.getAllGroupUUIDs().stream())
+                      // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+                      // against them just in case there is a bug or corner case.
+                      .filter(id -> id != null && id.get() != null))
+              .collect(toSet());
       logger.atFine().log("relevant group UUIDs: %s", relevantGroupUuids);
       return relevantGroupUuids;
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index fc4df49..929aa94 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -276,6 +276,7 @@
             .id(PatchSet.id(id, currentPatchSetId))
             .commitId(commitId)
             .uploader(Account.id(1000))
+            .realUploader(Account.id(1000))
             .createdOn(TimeUtil.now())
             .build();
     return cd;
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index c344edd..5f909a1 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -95,10 +95,9 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
    */
-  public static Predicate<ChangeData> starBy(
-      StarredChangesUtil starredChangesUtil, Account.Id id, String label) {
+  public static Predicate<ChangeData> starBy(StarredChangesUtil starredChangesUtil, Account.Id id) {
     Set<Predicate<ChangeData>> starredChanges =
-        starredChangesUtil.byAccountId(id, label).stream()
+        starredChangesUtil.byAccountId(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
     return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 714516c..b83b010 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -551,14 +551,14 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
         ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
         ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
@@ -641,7 +641,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -684,7 +684,7 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
+      checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
@@ -707,7 +707,7 @@
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.MERGE_SPEC, "is:merge");
+      checkOperatorAvailable(ChangeField.MERGE_SPEC, "is:merge");
       return new BooleanPredicate(ChangeField.MERGE_SPEC);
     }
 
@@ -716,7 +716,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -729,7 +729,7 @@
     }
 
     if ("pure-revert".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
+      checkOperatorAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
       return ChangePredicates.pureRevert("1");
     }
 
@@ -745,12 +745,12 @@
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
       }
-      checkFieldAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
+      checkOperatorAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
       return new IsSubmittablePredicate();
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.STARTED_SPEC, "is:started");
+      checkOperatorAvailable(ChangeField.STARTED_SPEC, "is:started");
       return new BooleanPredicate(ChangeField.STARTED_SPEC);
     }
 
@@ -759,7 +759,7 @@
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
+      checkOperatorAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
       return new BooleanPredicate(ChangeField.CHERRY_PICK_SPEC);
     }
 
@@ -894,7 +894,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
+    checkOperatorAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
@@ -904,7 +904,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
     return ChangePredicates.prefixHashtag(hashtag);
   }
 
@@ -930,7 +930,7 @@
       return ChangePredicates.exactTopic(name);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    checkOperatorAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
     return ChangePredicates.prefixTopic(name);
   }
 
@@ -996,7 +996,7 @@
 
   @Operator
   public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
-    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    checkOperatorAvailable(ChangeField.FOOTER_NAME, "hasfooter");
     return ChangePredicates.hasFooter(footerName);
   }
 
@@ -1147,7 +1147,9 @@
   @Operator
   public Predicate<ChangeData> message(String text) throws QueryParseException {
     if (text.startsWith("^")) {
-      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      checkFieldAvailable(
+          ChangeField.COMMIT_MESSAGE_EXACT,
+          "'message' operator with regular expression is not supported on this gerrit host");
       return new RegexMessagePredicate(text);
     }
     return ChangePredicates.message(text);
@@ -1155,19 +1157,19 @@
 
   @Operator
   public Predicate<ChangeData> subject(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+    checkOperatorAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
     return ChangePredicates.subject(value);
   }
 
   @Operator
   public Predicate<ChangeData> prefixsubject(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+    checkOperatorAvailable(
+        ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
     return ChangePredicates.prefixSubject(value);
   }
 
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
-    return ChangePredicates.starBy(
-        args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
+    return ChangePredicates.starBy(args.starredChangesUtil, self());
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
@@ -1249,7 +1251,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.UPLOADER_SPEC, "uploader");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1264,7 +1266,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
+    checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1309,7 +1311,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    checkFieldAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
 
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
@@ -1575,8 +1577,8 @@
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
     if (Ints.tryParse(value) != null) {
       return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
@@ -1648,11 +1650,16 @@
     return Predicate.or(predicates);
   }
 
-  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String operator)
+  private void checkOperatorAvailable(SchemaField<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String errorMessage)
       throws QueryParseException {
     if (!args.index.getSchema().hasField(field)) {
-      throw new QueryParseException(
-          String.format("'%s' operator is not supported on this gerrit host", operator));
+      throw new QueryParseException(errorMessage);
     }
   }
 
@@ -1766,7 +1773,8 @@
     return value;
   }
 
-  private Account.Id self() throws QueryParseException {
+  /** Returns {@link Account.Id} of the identified calling user. */
+  public Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
 
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index ccd645b..3edad69 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -103,6 +104,7 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
@@ -115,15 +117,6 @@
     return query(byBranchKeyPred(branch, key));
   }
 
-  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
-  }
-
-  public static Predicate<ChangeData> byBranchKeyOpenPred(
-      Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
-  }
-
   private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
     return and(ref(branch), project(branch.project()), change(key));
   }
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 3f4c158..698628e 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -19,8 +19,12 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.query.FileEditsPredicate;
-import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.ConstantPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexAuthorEmailPredicate;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.Locale;
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index edb12ec..616468e 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -29,6 +29,6 @@
 
   /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
   Predicate<ProjectData> parse(String query) throws QueryParseException;
-  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(List<String>)}. */
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(List)}. */
   List<Predicate<ProjectData>> parse(List<String> queries) throws QueryParseException;
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index e35ffdb..4b16143 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -11,91 +11,33 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
 package com.google.gerrit.server.restapi.account;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 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.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
-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.time.Instant;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
 
 @Singleton
 public class DeleteDraftComments
     implements RestModifyView<AccountResource, DeleteDraftCommentsInput> {
-
   private final Provider<CurrentUser> userProvider;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeQueryBuilder queryBuilder;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeJson.Factory changeJsonFactory;
-  private final Provider<CommentJson> commentJsonProvider;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
+  private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
 
   @Inject
   DeleteDraftComments(
-      Provider<CurrentUser> userProvider,
-      BatchUpdate.Factory batchUpdateFactory,
-      ChangeQueryBuilder queryBuilder,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeData.Factory changeDataFactory,
-      ChangeJson.Factory changeJsonFactory,
-      Provider<CommentJson> commentJsonProvider,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      Provider<CurrentUser> userProvider, DeleteDraftCommentsUtil deleteDraftCommentsUtil) {
     this.userProvider = userProvider;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilder = queryBuilder;
-    this.queryProvider = queryProvider;
-    this.changeDataFactory = changeDataFactory;
-    this.changeJsonFactory = changeJsonFactory;
-    this.commentJsonProvider = commentJsonProvider;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
+    this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
   }
 
   @Override
@@ -114,82 +56,6 @@
       // hasSameAccountId check.)
       throw new AuthException("Cannot delete drafts of other user");
     }
-
-    HumanCommentFormatter humanCommentFormatter =
-        commentJsonProvider.get().newHumanCommentFormatter();
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    Instant now = TimeUtil.now();
-    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
-    List<Op> ops = new ArrayList<>();
-    for (ChangeData cd :
-        queryProvider
-            .get()
-            // Don't attempt to mutate any changes the user can't currently see.
-            .enforceVisibility(true)
-            .query(predicate(accountId, input))) {
-      BatchUpdate update =
-          updates.computeIfAbsent(
-              cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
-      Op op = new Op(humanCommentFormatter, accountId);
-      update.addOp(cd.getId(), op);
-      ops.add(op);
-    }
-
-    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
-    // all updates from this operation only happen in All-Users and thus are fully atomic, so
-    // allowing partial failure would have little value.
-    BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
-
-    return Response.ok(
-        ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
-  }
-
-  private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
-      throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
-    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
-      return hasDraft;
-    }
-    try {
-      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
-    } catch (QueryParseException e) {
-      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final HumanCommentFormatter humanCommentFormatter;
-    private final Account.Id accountId;
-    private DeletedDraftCommentInfo result;
-
-    Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
-      this.humanCommentFormatter = humanCommentFormatter;
-      this.accountId = accountId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
-      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
-      boolean dirty = false;
-      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
-        dirty = true;
-        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
-        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
-        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
-        comments.add(humanCommentFormatter.format(c));
-      }
-      if (dirty) {
-        result = new DeletedDraftCommentInfo();
-        result.change =
-            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
-        result.deleted = comments.build();
-      }
-      return dirty;
-    }
-
-    @Nullable
-    DeletedDraftCommentInfo getResult() {
-      return result;
-    }
+    return Response.ok(deleteDraftCommentsUtil.deleteDraftComments(rsrc.getUser(), input.query));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
new file mode 100644
index 0000000..2ae3166
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CommentJson;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+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.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Singleton
+public class DeleteDraftCommentsUtil {
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeQueryBuilder queryBuilder;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final Provider<CommentJson> commentJsonProvider;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  public DeleteDraftCommentsUtil(
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeQueryBuilder queryBuilder,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeJson.Factory changeJsonFactory,
+      Provider<CommentJson> commentJsonProvider,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.queryBuilder = queryBuilder;
+    this.queryProvider = queryProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeJsonFactory = changeJsonFactory;
+    this.commentJsonProvider = commentJsonProvider;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+  }
+
+  public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
+      IdentifiedUser user, String query) throws RestApiException, UpdateException {
+    CommentJson.HumanCommentFormatter humanCommentFormatter =
+        commentJsonProvider.get().newHumanCommentFormatter();
+    Account.Id accountId = user.getAccountId();
+    Instant now = TimeUtil.now();
+    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
+    List<Op> ops = new ArrayList<>();
+    for (ChangeData cd :
+        queryProvider
+            .get()
+            // Don't attempt to mutate any changes the user can't currently see.
+            .enforceVisibility(true)
+            .query(predicate(accountId, query))) {
+      BatchUpdate update =
+          updates.computeIfAbsent(cd.project(), p -> batchUpdateFactory.create(p, user, now));
+      Op op = new Op(humanCommentFormatter, accountId);
+      update.addOp(cd.getId(), op);
+      ops.add(op);
+    }
+    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
+    // all updates from this operation only happen in All-Users and thus are fully atomic, so
+    // allowing partial failure would have little value.
+    BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+    return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
+  }
+
+  private Predicate<ChangeData> predicate(Account.Id accountId, String query)
+      throws BadRequestException {
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
+    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(query)).isEmpty()) {
+      return hasDraft;
+    }
+    try {
+      return Predicate.and(hasDraft, queryBuilder.parse(query));
+    } catch (QueryParseException e) {
+      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final CommentJson.HumanCommentFormatter humanCommentFormatter;
+    private final Account.Id accountId;
+    private DeletedDraftCommentInfo result;
+
+    Op(CommentJson.HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+      this.humanCommentFormatter = humanCommentFormatter;
+      this.accountId = accountId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
+      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
+      boolean dirty = false;
+      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+        dirty = true;
+        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(humanCommentFormatter.format(c));
+      }
+      if (dirty) {
+        result = new DeletedDraftCommentInfo();
+        result.change =
+            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
+        result.deleted = comments.build();
+      }
+      return dirty;
+    }
+
+    @Nullable
+    DeletedDraftCommentInfo getResult() {
+      return result;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 1a8f07a..6535e42 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -18,16 +18,23 @@
 
 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;
@@ -37,27 +44,35 @@
 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.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;
@@ -65,16 +80,22 @@
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final PatchSetUtil patchSetUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
   public Rebase(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      PatchSetUtil patchSetUtil) {
+      PatchSetUtil patchSetUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ChangeResource.Factory changeResourceFactory) {
+    this.serverIdent = serverIdent;
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
     this.rebaseUtil = rebaseUtil;
@@ -82,11 +103,26 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.patchSetUtil = patchSetUtil;
+    this.userFactory = userFactory;
+    this.changeResourceFactory = changeResourceFactory;
   }
 
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
+
+    if (input.onBehalfOfUploader && !rsrc.getPatchSet().uploader().equals(rsrc.getAccountId())) {
+      rsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+      rsrc = onBehalfOf(rsrc, input);
+    } else {
+      rsrc.permissions().check(ChangePermission.REBASE);
+    }
+
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
+
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
@@ -94,7 +130,7 @@
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      RebaseUtil.verifyRebasePreconditions(projectCache, patchSetUtil, rw, rsrc);
+      rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
 
       RebaseChangeOp rebaseOp =
           rebaseUtil.getRebaseOp(
@@ -115,6 +151,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
+   */
+  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 =
@@ -147,9 +320,17 @@
       }
     }
 
-    if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
-      return description.setVisible(true).setEnabled(enabled);
+    boolean canRebase = rsrc.permissions().testOrFalse(ChangePermission.REBASE);
+    boolean canRebaseOnBehalfOfUploader =
+        rsrc.permissions().testOrFalse(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+    if (canRebase || canRebaseOnBehalfOfUploader) {
+      return description
+          .setOption("rebase", canRebase)
+          .setOption("rebase_on_behalf_of_uploader", canRebaseOnBehalfOfUploader)
+          .setEnabled(enabled)
+          .setVisible(true);
     }
+
     return description;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
index 4754c69..5ae496f 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChain.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RebaseChainInfo;
+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;
@@ -113,7 +114,16 @@
   @Override
   public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
       throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    tipRsrc.permissions().check(ChangePermission.REBASE);
+
+    if (input.onBehalfOfUploader) {
+      throw new BadRequestException(
+          "rebasing on behalf of the uploader is not supported when rebasing a chain");
+    }
+
     Project.NameKey project = tipRsrc.getProject();
+    projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
+
     CurrentUser user = tipRsrc.getUser();
 
     List<Change.Id> upToDateAncestors = new ArrayList<>();
@@ -136,7 +146,8 @@
 
         RevisionResource revRsrc =
             new RevisionResource(changeResourceFactory.create(changeData, user), ps);
-        RebaseUtil.verifyRebasePreconditions(projectCache, patchSetUtil, rw, revRsrc);
+        revRsrc.permissions().check(ChangePermission.REBASE);
+        rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
 
         boolean isUpToDate = false;
         RebaseChangeOp rebaseOp = null;
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index bdc6816..8ab8a19 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -157,6 +157,7 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
+              .realUploader(change.getUser().getAccountId())
               .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 491b5cd..f3c741f 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -37,6 +37,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.stream.Collectors;
 import javax.inject.Inject;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -216,12 +217,19 @@
                   Arrays.asList(
                       cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
               .build();
+      ImmutableList<String> refPatterns =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+              .build();
       LabelAttributes attributes =
           LabelAttributes.create(
               function == null ? "MaxWithBlock" : function,
               canOverride,
               ignoreSelfApproval,
-              values);
+              values,
+              refPatterns);
       labelTypes.put(labelName, attributes);
     }
     return labelTypes;
@@ -320,6 +328,15 @@
       default:
         break;
     }
+    if (!attributes.refPatterns().isEmpty()) {
+      builder.setApplicabilityExpression(
+          SubmitRequirementExpression.of(
+              String.join(
+                  " OR ",
+                  attributes.refPatterns().stream()
+                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .collect(Collectors.toList()))));
+    }
     return builder.build();
   }
 
@@ -435,13 +452,16 @@
 
     abstract ImmutableList<String> values();
 
+    abstract ImmutableList<String> refPatterns();
+
     static LabelAttributes create(
         String function,
         boolean canOverride,
         boolean ignoreSelfApproval,
-        ImmutableList<String> values) {
+        ImmutableList<String> values,
+        ImmutableList<String> refPatterns) {
       return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
-          function, canOverride, ignoreSelfApproval, values);
+          function, canOverride, ignoreSelfApproval, values, refPatterns);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
similarity index 85%
rename from java/com/google/gerrit/server/query/change/ConstantPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
index f0a85fe..c493fa4 100644
--- a/java/com/google/gerrit/server/query/change/ConstantPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
@@ -12,8 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Singleton;
 
 /**
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
similarity index 95%
rename from java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
index 5a51f5d..e392989 100644
--- a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -22,6 +22,8 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
similarity index 98%
rename from java/com/google/gerrit/server/query/FileEditsPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
index 7058765..515dc4a 100644
--- a/java/com/google/gerrit/server/query/FileEditsPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Iterables;
diff --git a/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
similarity index 95%
rename from java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
index 4ff40a4..1774628 100644
--- a/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import static com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder.SUBMODULE_UPDATE_HAS_ARG;
 
@@ -23,6 +23,8 @@
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
similarity index 89%
rename from java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
index 22891bc..eb7f666 100644
--- a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
@@ -12,9 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b828037..936b448 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -193,6 +194,7 @@
     install(new AuthModule(authConfig));
     install(new GerritApiModule());
     install(new ProjectQueryBuilderModule());
+    install(new DefaultRefLogIdentityProvider.Module());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index c8f89cf..5a3c755 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -73,6 +73,7 @@
         .id(id)
         .commitId(ObjectId.fromString(revision))
         .uploader(userId)
+        .realUploader(userId)
         .createdOn(TimeUtil.now())
         .build();
   }
diff --git a/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
new file mode 100644
index 0000000..27e4b17
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.reflect.ClassPath;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Utility to generate Protocol Buffers (*.proto) files from existing POJO API types.
+ *
+ * <p>Usage:
+ *
+ * <ul>
+ *   <li>Print proto representation of all API objects: {@code bazelisk run
+ *       java/com/google/gerrit/util/cli:protogen}
+ * </ul>
+ */
+public class ApiProtocolBufferGenerator {
+  private static String NOTICE =
+      "// Copyright (C) 2023 The Android Open Source Project\n"
+          + "//\n"
+          + "// Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+          + "// you may not use this file except in compliance with the License.\n"
+          + "// You may obtain a copy of the License at\n"
+          + "//\n"
+          + "// http://www.apache.org/licenses/LICENSE-2.0\n"
+          + "//\n"
+          + "// Unless required by applicable law or agreed to in writing, software\n"
+          + "// distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+          + "// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+          + "// See the License for the specific language governing permissions and\n"
+          + "// limitations under the License.";
+
+  private static String PACKAGE = "com.google.gerrit.extensions.common";
+
+  public static void main(String[] args) {
+    try {
+      ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+          .filter(c -> c.getPackageName().equalsIgnoreCase(PACKAGE))
+          .filter(c -> c.getName().endsWith("Input") || c.getName().endsWith("Info"))
+          .map(clazz -> clazz.load())
+          .forEach(ApiProtocolBufferGenerator::exportSingleClass);
+    } catch (Exception e) {
+      System.err.println(e);
+    }
+  }
+
+  private static void exportSingleClass(Class<?> clazz) {
+    StringBuilder proto = new StringBuilder(NOTICE);
+    proto.append("\n\nsyntax = \"proto3\";");
+    proto.append("\n\npackage gerrit.api;");
+    proto.append("\n\noption java_package = \"" + PACKAGE + "\";");
+
+    int fieldNumber = 1;
+
+    proto.append("\n\n\nmessage " + clazz.getSimpleName() + " {\n");
+
+    for (Field f : clazz.getFields()) {
+      Class<?> type = f.getType();
+
+      if (type.isAssignableFrom(List.class)) {
+        ParameterizedType list = (ParameterizedType) f.getGenericType();
+        Class<?> genericType = (Class<?>) list.getActualTypeArguments()[0];
+        String protoType =
+            protoType(genericType)
+                .orElseThrow(() -> new IllegalStateException("unknown type: " + genericType));
+        proto.append(
+            String.format(
+                "repeated %s %s = %d;\n", protoType, protoName(f.getName()), fieldNumber));
+      } else if (type.isAssignableFrom(Map.class)) {
+        ParameterizedType map = (ParameterizedType) f.getGenericType();
+        Class<?> key = (Class<?>) map.getActualTypeArguments()[0];
+        if (map.getActualTypeArguments()[1] instanceof ParameterizedType) {
+          // TODO: This is list multimap which proto doesn't support. Move to
+          // it's own types.
+          proto.append(
+              "reserved "
+                  + fieldNumber
+                  + "; // TODO(hiesel): Add support for map<?,repeated <?>>\n");
+        } else {
+          Class<?> value = (Class<?>) map.getActualTypeArguments()[1];
+          String keyProtoType =
+              protoType(key).orElseThrow(() -> new IllegalStateException("unknown type: " + key));
+          String valueProtoType =
+              protoType(value)
+                  .orElseThrow(() -> new IllegalStateException("unknown type: " + value));
+          proto.append(
+              String.format(
+                  "map<%s,%s> %s = %d;\n",
+                  keyProtoType, valueProtoType, protoName(f.getName()), fieldNumber));
+        }
+      } else if (protoType(type).isPresent()) {
+        proto.append(
+            String.format(
+                "%s %s = %d;\n", protoType(type).get(), protoName(f.getName()), fieldNumber));
+      } else {
+        proto.append(
+            "reserved "
+                + fieldNumber
+                + "; // TODO(hiesel): Add support for "
+                + type.getName()
+                + "\n");
+      }
+      fieldNumber++;
+    }
+    proto.append("}");
+
+    System.out.println(proto);
+  }
+
+  private static Optional<String> protoType(Class<?> type) {
+    if (isInt(type)) {
+      return Optional.of("int32");
+    } else if (isLong(type)) {
+      return Optional.of("int64");
+    } else if (isChar(type)) {
+      return Optional.of("string");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isBoolean(type)) {
+      return Optional.of("bool");
+    } else if (type.isAssignableFrom(String.class)) {
+      return Optional.of("string");
+    } else if (type.isAssignableFrom(Timestamp.class)) {
+      // See https://gerrit-review.googlesource.com/Documentation/rest-api.html#timestamp
+      return Optional.of("string");
+    } else if (type.getPackageName().startsWith("com.google.gerrit.extensions")) {
+      return Optional.of("gerrit.api." + type.getSimpleName());
+    }
+    return Optional.empty();
+  }
+
+  private static boolean isInt(Class<?> type) {
+    return type.isAssignableFrom(Integer.class) || type.isAssignableFrom(int.class);
+  }
+
+  private static boolean isLong(Class<?> type) {
+    return type.isAssignableFrom(Long.class) || type.isAssignableFrom(long.class);
+  }
+
+  private static boolean isChar(Class<?> type) {
+    return type.isAssignableFrom(Character.class) || type.isAssignableFrom(char.class);
+  }
+
+  private static boolean isShort(Class<?> type) {
+    return type.isAssignableFrom(Short.class) || type.isAssignableFrom(short.class);
+  }
+
+  private static boolean isBoolean(Class<?> type) {
+    return type.isAssignableFrom(Boolean.class) || type.isAssignableFrom(boolean.class);
+  }
+
+  private static String protoName(String name) {
+    return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, name);
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index ebcc67e..b464f32 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -2,7 +2,10 @@
 
 java_library(
     name = "cli",
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["ApiProtocolBufferGenerator.java"],
+    ),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
@@ -14,3 +17,15 @@
         "//lib/guice:guice-assistedinject",
     ],
 )
+
+# Util to generate *.proto files from *Info and *Input objects
+java_binary(
+    name = "protogen",
+    srcs = ["ApiProtocolBufferGenerator.java"],
+    main_class = "com.google.gerrit.util.cli.ApiProtocolBufferGenerator",
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib:protobuf",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 74bd94e..b1fb575 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -399,7 +399,11 @@
       String changeId = r2.getChangeId();
       requestScopeOperations.setApiUser(user.id());
       AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
-      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
     }
 
     @Test
@@ -449,7 +453,11 @@
       String changeId = r2.getChangeId();
       requestScopeOperations.setApiUser(user.id());
       AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
-      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
     }
 
     @Test
@@ -473,7 +481,11 @@
       // Rebase the second
       String changeId = r2.getChangeId();
       AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
-      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
     }
 
     @Test
@@ -527,6 +539,7 @@
 
       RevisionInfo r = info.revisions.get(info.currentRevision);
       assertThat(r._number).isEqualTo(expectedNumRevisions);
+      assertThat(r.realUploader).isNull();
 
       // ...and the base should be correct
       assertThat(r.commit.parents).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
new file mode 100644
index 0000000..7030804
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -0,0 +1,1073 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+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.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+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.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.junit.Test;
+
+public class RebaseOnBehalfOfUploaderIT extends AbstractDaemonTest {
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void cannotRebaseOnBehalfOfUploaderWithAllowConflicts() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    rebaseInput.allowConflicts = true;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+  }
+
+  @Test
+  public void cannotRebaseNonCurrentPatchSetOnBehalfOfUploader() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+    changeOperations.change(changeId).newPatchset().create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).revision(1).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("non-current patch set cannot be rebased on behalf of the uploader");
+  }
+
+  @Test
+  public void cannotRebaseChainOnBehalfOfUploader() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("rebasing on behalf of the uploader is not supported when rebasing a chain");
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploader_withRebasePermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.REBASE,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+  }
+
+  @Test
+  public void rebaseCurrentPatchSetOnBehalfOfUploader_withRebasePermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.REBASE,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).current().rebase(rebaseInput));
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploader_withSubmitPermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.SUBMIT,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+  }
+
+  @Test
+  public void rebaseCurrentPatchSetOnBehalfOfUploader_withSubmitPermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.SUBMIT,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).current().rebase(rebaseInput));
+  }
+
+  private void testRebaseChangeOnBehalfOfUploader(String permissionToAllow, RebaseCall rebaseCall)
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Grant permission to rebaser that is required to rebase on behalf of the uploader.
+    AccountGroup.UUID allowedGroup =
+        groupOperations.newGroup().name("can-" + permissionToAllow).addMember(rebaser).create();
+    allowPermission(permissionToAllow, allowedGroup);
+
+    // Block rebase and submit permission for uploader. For rebase on behalf of the uploader only
+    // the rebaser needs to have this permission, but not the uploader on whom's behalf the rebase
+    // is done.
+    AccountGroup.UUID cannotRebaseAndSubmitGroup =
+        groupOperations.newGroup().name("cannot-rebase").addMember(uploader).create();
+    blockPermission(Permission.REBASE, cannotRebaseAndSubmitGroup);
+    blockPermission(Permission.SUBMIT, cannotRebaseAndSubmitGroup);
+
+    // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+    // doesn't require the rebaser to have the push permission.
+    AccountGroup.UUID cannotUploadGroup =
+        groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+    blockPermission(Permission.PUSH, cannotUploadGroup);
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Create a second patch set for the change that will be rebased so that the uploader is
+    // different to the change owner. This is to verify that being change owner doesn't matter for
+    // the user on whom's behalf the rebase is done.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+
+    TestRevisionCreatedListener testRevisionCreatedListener = new TestRevisionCreatedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRevisionCreatedListener)) {
+      rebaseCall.call(changeToBeRebased, rebaseInput);
+
+      assertThat(testRevisionCreatedListener.revisionInfo.uploader._accountId)
+          .isEqualTo(uploader.get());
+      assertThat(testRevisionCreatedListener.revisionInfo.realUploader._accountId)
+          .isEqualTo(rebaser.get());
+    }
+
+    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    RevisionInfo currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    // 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());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+    assertThat(currentRevisionInfo.commit.author.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+
+    // Verify that the rebaser was recorded as realUser in NoteDb.
+    Optional<FooterLine> realUserFooter =
+        projectOperations.project(project).getHead(RefNames.changeMetaRef(changeToBeRebased))
+            .getFooterLines().stream()
+            .filter(footerLine -> footerLine.matches(FOOTER_REAL_USER))
+            .findFirst();
+    assertThat(realUserFooter.map(FooterLine::getValue))
+        .hasValue(
+            String.format(
+                "%s <%s>",
+                ChangeNoteUtil.getAccountIdAsUsername(rebaser),
+                changeNoteUtil.getAccountIdAsEmailAddress(rebaser)));
+
+    // Verify the message that has been posted on the change.
+    Collection<ChangeMessageInfo> changeMessages = changeInfo2.messages;
+    // Before the rebase the change had 2 messages for the upload of the 2 patch sets. Rebase is
+    // expected to add another message.
+    assertThat(changeMessages).hasSize(3);
+    ChangeMessageInfo changeMessage = Iterables.getLast(changeMessages);
+    assertThat(changeMessage.message)
+        .isEqualTo(
+            "Patch Set 3: Patch Set 2 was rebased on behalf of "
+                + AccountTemplateUtil.getAccountTemplate(uploader));
+    assertThat(changeMessage.author._accountId).isEqualTo(uploader.get());
+    assertThat(changeMessage.realAuthor._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    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);
+    // 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);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Rebase the change once again on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    // 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);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase3 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase3.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase3.get()).current().submit();
+
+    // Rebase the change once again on behalf of the uploader, this time by another rebaser.
+    Account.Id rebaser2 = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(rebaser2);
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    currentRevisionInfo = changeInfo2.revisions.get(changeInfo2.currentRevision);
+    // 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);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser2.get());
+  }
+
+  @Test
+  public void nonChangeOwnerWithoutSubmitAndRebasePermissionCannotRebaseOnBehalfOfUploader()
+      throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    blockPermissionForAllUsers(Permission.REBASE);
+    blockPermissionForAllUsers(Permission.SUBMIT);
+
+    Account.Id rebaserId = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(rebaserId);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    AuthException exception =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "rebase on behalf of uploader not permitted (change owners and users with the 'Submit'"
+                + " or 'Rebase' permission can rebase on behalf of the uploader)");
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoReadPermission()
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+        uploaderEmail,
+        Permission.READ,
+        String.format("uploader %s cannot read change", uploaderEmail));
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPushPermission()
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+        uploaderEmail,
+        Permission.PUSH,
+        String.format("uploader %s cannot add patch set", uploaderEmail));
+  }
+
+  private void testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+      String uploaderEmail, String permissionToBlock, String expectedErrorMessage)
+      throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block the required permission for uploader. Without this permission it should not be possible
+    // to rebase the change on behalf of the uploader.
+    AccountGroup.UUID blockedGroup =
+        groupOperations.newGroup().name("cannot-" + permissionToBlock).addMember(uploader).create();
+    blockPermission(permissionToBlock, blockedGroup);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception).hasMessageThat().isEqualTo(expectedErrorMessage);
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfYourself() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change as uploader on behalf of the uploader
+    requestScopeOperations.setApiUser(uploader);
+    RebaseInput rebaseInput = new RebaseInput();
+    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);
+    // 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());
+    assertThat(currentRevisionInfo.realUploader).isNull();
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfYourselfWithoutPushPermission() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block push for uploader. For rebase on behalf of the uploader only
+    // the rebaser needs to have this permission, but not the uploader on whom's behalf the rebase
+    // is done.
+    AccountGroup.UUID cannotPushGroup =
+        groupOperations.newGroup().name("cannot-push").addMember(uploader).create();
+    blockPermission(Permission.PUSH, cannotPushGroup);
+
+    // Rebase the second change as uploader on behalf of the uploader
+    requestScopeOperations.setApiUser(uploader);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    AuthException exception =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                + " permission can rebase if they have the 'Push' permission)");
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwner() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Create a second patch set for the change that will be rebased so that the uploader is
+    // different to the change owner.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant add patch set permission for uploader. Without the add patch set permission it is not
+    // possible to rebase the change on behalf of the uploader since the uploader cannot add a
+    // patch set to a change that is owned by another user.
+    AccountGroup.UUID canAddPatchSet =
+        groupOperations.newGroup().name("can-add-patch-set").addMember(uploader).create();
+    allowPermission(Permission.ADD_PATCH_SET, canAddPatchSet);
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    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);
+    // 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());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChangeOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwnerAndDoesntHaveAddPatchSetPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Create a second patch set for the change that will be rebased so that the uploader is
+    // different to the change owner.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block add patch set permission for uploader. Without the add patch set permission it should
+    // not possible to rebase the change on behalf of the uploader since the uploader cannot add a
+    // patch set to a change that is owned by another user.
+    AccountGroup.UUID cannotAddPatchSet =
+        groupOperations.newGroup().name("cannot-add-patch-set").addMember(uploader).create();
+    blockPermission(Permission.ADD_PATCH_SET, cannotAddPatchSet);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("uploader %s cannot add patch set", uploaderEmail));
+  }
+
+  @Test
+  public void rebaseChangeWithForgedAuthorOnBehalfOfUploader() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String authorEmail = "author@example.com";
+    Account.Id author = accountOperations.newAccount().preferredEmail(authorEmail).create();
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).author(author).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author permission for uploader. Without the forge author permission it is not
+    // possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthor =
+        groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    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);
+    // 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);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChangeWithForgedAuthorOnBehalfOfUploaderIfTheUploaderHasNoForgeAuthorPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id author = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).author(author).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block forge author permission for uploader. Without the forge author permission it should not
+    // be possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID cannotForgeAuthor =
+        groupOperations.newGroup().name("cannot-forge-author").addMember(uploader).create();
+    blockPermission(Permission.FORGE_AUTHOR, cannotForgeAuthor);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "author of patch set 1 is forged and the uploader %s cannot forge author",
+                uploaderEmail));
+  }
+
+  @Test
+  public void
+      rebaseChangeWithForgedCommitterOnBehalfOfUploaderDoesntRequireForgeCommitterPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id committer =
+        accountOperations.newAccount().preferredEmail("committer@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the committer of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).committer(committer).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    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);
+    // 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);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void rebaseChangeWithServerIdentOnBehalfOfUploader() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Use the server identity as the author of the
+    // change that will be rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author and forge server permission for uploader. Without these permissions it is
+    // not possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthorAndForgeServer =
+        groupOperations
+            .newGroup()
+            .name("can-forge-author-and-forge-server")
+            .addMember(uploader)
+            .create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthorAndForgeServer);
+    allowPermission(Permission.FORGE_SERVER, canForgeAuthorAndForgeServer);
+
+    // Rebase the second change on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    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);
+    // 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(serverIdent.get().getEmailAddress());
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChangeWithServerIdentOnBehalfOfUploaderIfTheUploaderHasNoForgeServerPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Use the server identity as the author of the
+    // change that will be rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author permission for uploader, but not the forge server permission. Without the
+    // forge server permission it is not possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthor =
+        groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+    // Try to rebase the second change on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "author of patch set 1 is the server identity and the uploader %s cannot forge"
+                    + " the server identity",
+                uploaderEmail));
+  }
+
+  @Test
+  public void rebaseActionEnabled_withRebasePermission() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+    testRebaseActionEnabled();
+  }
+
+  @Test
+  public void rebaseActionEnabled_withSubmitPermission() throws Exception {
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testRebaseActionEnabled();
+  }
+
+  private void testRebaseActionEnabled() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+    // doesn't require the rebaser to have the push permission.
+    AccountGroup.UUID cannotUploadGroup =
+        groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+    blockPermission(Permission.PUSH, cannotUploadGroup);
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    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);
+    assertThat(revisionInfo.actions).containsKey("rebase");
+    ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
+    assertThat(rebaseActionInfo.enabled).isTrue();
+
+    // rebase is disabled because rebaser doesn't have the 'Push' permission and hence cannot create
+    // new patch sets
+    assertThat(rebaseActionInfo.enabledOptions).containsExactly("rebase_on_behalf_of_uploader");
+  }
+
+  @Test
+  public void rebaseActionEnabled_forChangeOwner() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    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);
+    assertThat(revisionInfo.actions).containsKey("rebase");
+    ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
+    assertThat(rebaseActionInfo.enabled).isTrue();
+
+    // rebase is disabled because change owner has the 'Push' permission and hence can create new
+    // patch sets
+    assertThat(rebaseActionInfo.enabledOptions)
+        .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+  }
+
+  @UseLocalDisk
+  @Test
+  public void rebaseChangeOnBehalfOfUploaderRecordsUploaderInRefLog() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = RefNames.changeMetaRef(changeToBeRebased);
+      String patchSetRef = RefNames.patchSetRef(PatchSet.id(changeToBeRebased, 2));
+      createRefLogFileIfMissing(repo, changeMetaRef);
+      createRefLogFileIfMissing(repo, patchSetRef);
+
+      // Rebase the second change on behalf of the uploader
+      requestScopeOperations.setApiUser(rebaser);
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.onBehalfOfUploader = true;
+      gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+      // The ref log for the patch set ref records the impersonated user aka the uploader.
+      ReflogEntry patchSetRefLogEntry = repo.getReflogReader(patchSetRef).getLastEntry();
+      assertThat(patchSetRefLogEntry.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+
+      // The ref log for the change meta ref records the impersonated user aka the uploader.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+    }
+  }
+
+  @Test
+  public void rebaserCanApproveChangeAfterRebasingOnBehalfOfUploader() throws Exception {
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    // Approve the change as the rebaser.
+    allowVotingOnCodeReviewToAllUsers();
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+
+    // The change is submittable because the approval is from a user (the rebaser) that is not the
+    // uploader.
+    assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isTrue();
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Doing a normal rebase (not on behalf of the uploader) makes the rebaser the uploader. This
+    // makse the change non-submittable since the approval of the rebaser is ignored now (due to
+    // using 'user=non_uploader' in the submit requirement expression).
+    requestScopeOperations.setApiUser(rebaser);
+    rebaseInput.onBehalfOfUploader = false;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+    assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isFalse();
+  }
+
+  private void allowPermissionToAllUsers(String permission) {
+    allowPermission(permission, REGISTERED_USERS);
+  }
+
+  private void allowPermission(String permission, AccountGroup.UUID groupUuid) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(permission).ref("refs/*").group(groupUuid))
+        .update();
+  }
+
+  private void allowVotingOnCodeReviewToAllUsers() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+  }
+
+  private void blockPermissionForAllUsers(String permission) {
+    blockPermission(permission, REGISTERED_USERS);
+  }
+
+  private void blockPermission(String permission, AccountGroup.UUID groupUuid) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(permission).ref("refs/*").group(groupUuid))
+        .update();
+  }
+
+  @FunctionalInterface
+  private interface RebaseCall {
+    void call(Change.Id changeId, RebaseInput rebaseInput) throws RestApiException;
+  }
+
+  private static class TestRevisionCreatedListener implements RevisionCreatedListener {
+    public RevisionInfo revisionInfo;
+
+    @Override
+    public void onRevisionCreated(Event event) {
+      revisionInfo = event.getRevision();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 242c278..ab2f358 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
@@ -424,6 +425,26 @@
   }
 
   @Test
+  public void checkSubmitRequirement_verifiesUploader() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "Code-Review", 2);
+    TestAccount anotherUser = accountCreator.createValid("anotherUser");
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Foo", /* submittabilityExpression= */ "uploader:" + anotherUser.id());
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    in =
+        createSubmitRequirementInput(
+            "Foo", /* submittabilityExpression= */ "uploader:" + r.getChange().change().getOwner());
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
   public void submitRequirement_withLabelEqualsMax() throws Exception {
     configSubmitRequirement(
         project,
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 74bfe0f..9d37497 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -260,6 +261,96 @@
   }
 
   @Test
+  public void migrateBlockingLabel_withBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withMultipleBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "refs/heads/develop"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"refs/heads/develop\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexAndNonRegexBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
   public void migrationIsIdempotent() throws Exception {
     String oldRefsConfigId;
     try (Repository repo = repoManager.openRepository(project)) {
@@ -381,6 +472,21 @@
     gApi.projects().name(project.get()).label(labelName).create(input);
   }
 
+  private void createLabelWithBranch(
+      String labelName,
+      String function,
+      boolean ignoreSelfApproval,
+      ImmutableList<String> refPatterns)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = refPatterns;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
   @CanIgnoreReturnValue
   private SubmitRequirementApi createSubmitRequirement(
       String name, String submitExpression, boolean canOverride) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index d055875..62ef118 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RefLogIdentityProvider;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.DefaultRealm;
@@ -57,6 +58,7 @@
 
 public class EmailIT extends AbstractDaemonTest {
   @Inject private @AnonymousCowardName String anonymousCowardName;
+  @Inject private RefLogIdentityProvider refLogIdentityProvider;
   @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
   @Inject private @EnablePeerIPInReflogRecord boolean enablePeerIPInReflogRecord;
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
@@ -283,6 +285,7 @@
             authConfig,
             realm,
             anonymousCowardName,
+            refLogIdentityProvider,
             canonicalUrl,
             enablePeerIPInReflogRecord,
             accountCache,
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 3531d1c..03a1529 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -70,6 +70,7 @@
 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.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -78,8 +79,6 @@
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import java.io.File;
-import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -143,11 +142,7 @@
       assertThat(psa.value()).isEqualTo(1);
       assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
-      ChangeData cd = r.getChange();
-      ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-      assertThat(m.getMessage()).endsWith(in.message);
-      assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-      assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+      assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
 
       // The change meta commit is created by the server and has the impersonated user as the
       // author.
@@ -187,11 +182,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
 
     // realUser2 votes Code-Review+1 on behalf of impersonatedUser, this should override the
     // impersonated Code-Review+1 of realUser with an impersonated Code-Review+1 of realUser2
@@ -208,11 +199,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
 
-    cd = r.getChange();
-    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(realUser2.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser2);
   }
 
   @Test
@@ -237,11 +224,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
 
     // realUser2 votes Code-Review-1 on behalf of impersonatedUser, this should override the
     // impersonated Code-Review+1 of realUser with an impersonated Code-Review-1 of realUser2
@@ -258,11 +241,7 @@
     assertThat(psa.value()).isEqualTo(-1);
     assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
 
-    cd = r.getChange();
-    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(realUser2.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser2);
   }
 
   @Test
@@ -286,11 +265,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
 
     // impersonatedUser votes Code-Review+1 themselves, this should override the impersonated
     // Code-Review+1 with a non-impersonated Code-Review+1
@@ -306,11 +281,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
 
-    cd = r.getChange();
-    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(impersonatedUser.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, impersonatedUser);
   }
 
   @Test
@@ -334,11 +305,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
 
     // impersonatedUser votes Code-Review-1 themselves, this should override the impersonated
     // Code-Review+1 with a non-impersonated Code-Review-1
@@ -354,11 +321,7 @@
     assertThat(psa.value()).isEqualTo(-1);
     assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
 
-    cd = r.getChange();
-    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
-    assertThat(m.getRealAuthor()).isEqualTo(impersonatedUser.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, impersonatedUser);
   }
 
   @Test
@@ -879,11 +842,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id());
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id()); // not user2
+    assertLastChangeMessage(r.getChange(), in.message, user, admin);
   }
 
   @Test
@@ -902,10 +861,30 @@
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
     assertThat(info.messages).hasSize(2);
 
-    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
-    assertThat(changeMessageInfo.realAuthor).isNotNull();
-    assertThat(changeMessageInfo.realAuthor._accountId)
-        .isEqualTo(accountCreator.user2().id().get());
+    assertLastChangeMessage(r.getChange(), in.message, user, accountCreator.user2());
+  }
+
+  private void assertLastChangeMessage(
+      ChangeData changeData,
+      String expectedMessage,
+      TestAccount expectedAuthor,
+      TestAccount expectedRealAuthor)
+      throws RestApiException {
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(changeData.notes()));
+    assertThat(m.getMessage()).endsWith(expectedMessage);
+    assertThat(m.getAuthor()).isEqualTo(expectedAuthor.id());
+    assertThat(m.getRealAuthor()).isEqualTo(expectedRealAuthor.id());
+
+    ChangeMessageInfo lastChangeMessageInfo =
+        Iterables.getLast(gApi.changes().id(changeData.getId().get()).get().messages);
+    assertThat(lastChangeMessageInfo.message).endsWith(expectedMessage);
+    assertThat(lastChangeMessageInfo.author._accountId).isEqualTo(expectedAuthor.id().get());
+    if (expectedAuthor.id().equals(expectedRealAuthor.id())) {
+      assertThat(lastChangeMessageInfo.realAuthor).isNull();
+    } else {
+      assertThat(lastChangeMessageInfo.realAuthor._accountId)
+          .isEqualTo(expectedRealAuthor.id().get());
+    }
   }
 
   private void allowCodeReviewOnBehalfOf() throws Exception {
@@ -965,12 +944,4 @@
   private static Header runAsHeader(Object user) {
     return new BasicHeader("X-Gerrit-RunAs", user.toString());
   }
-
-  private void createRefLogFileIfMissing(Repository repo, String ref) throws IOException {
-    File log = new File(repo.getDirectory(), "logs/" + ref);
-    if (!log.exists()) {
-      log.getParentFile().mkdirs();
-      assertThat(log.createNewFile()).isTrue();
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 2123ac2..15baa78 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1262,7 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
@@ -1274,7 +1274,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
                 + "\n"
@@ -1288,7 +1288,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -1300,7 +1300,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -1312,7 +1312,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -1324,7 +1324,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index b2a0ded..e011ffc 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -68,10 +68,7 @@
       values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
-    assertThat(
-            experimentFeatures.isFeatureEnabled(
-                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
-        .isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("UiFeature__patchset_comments")).isFalse();
     assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 6c629c9..3b97372 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -29,6 +29,7 @@
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
@@ -49,6 +50,7 @@
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
 public class ChangeOperationsImplTest extends AbstractDaemonTest {
@@ -611,6 +613,155 @@
   }
 
   @Test
+  public void createdChangeHasOwnerAsAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(changeOwner.fullname().get());
+    assertThat(revision.commit.author.email).isEqualTo(changeOwner.preferredEmail().get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwnerAsAuthor() throws Exception {
+    String changeOwnerName = "Change Owner";
+    String changeOwnerEmail = "change-owner@example.com";
+    Account.Id changeOwner =
+        accountOperations
+            .newAccount()
+            .fullname(changeOwnerName)
+            .preferredEmail(changeOwnerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(changeOwnerName);
+    assertThat(revision.commit.author.email).isEqualTo(changeOwnerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedAuthor() throws Exception {
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    Change.Id changeId = changeOperations.newChange().author(author).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(author.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedAuthorIdent() throws Exception {
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+    Change.Id changeId = changeOperations.newChange().authorIdent(authorIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+    assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+    Account.Id author = accountOperations.newAccount().create();
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> changeOperations.newChange().author(author).authorIdent(authorIdent).create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("author and authorIdent cannot be set together");
+  }
+
+  @Test
+  public void createdChangeHasOwnerAsCommitter() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(changeOwner.fullname().get());
+    assertThat(revision.commit.committer.email).isEqualTo(changeOwner.preferredEmail().get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwnerAsCommitter() throws Exception {
+    String changeOwnerName = "Change Owner";
+    String changeOwnerEmail = "change-owner@example.com";
+    Account.Id changeOwner =
+        accountOperations
+            .newAccount()
+            .fullname(changeOwnerName)
+            .preferredEmail(changeOwnerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(changeOwnerName);
+    assertThat(revision.commit.committer.email).isEqualTo(changeOwnerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitter() throws Exception {
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().committer(committer).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitterIdent() throws Exception {
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+    Change.Id changeId = changeOperations.newChange().committerIdent(committerIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+    assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+    Account.Id committer = accountOperations.newAccount().create();
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .committer(committer)
+                    .committerIdent(committerIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer and committerIdent cannot be set together");
+  }
+
+  @Test
   public void createdChangeHasSpecifiedTopic() throws Exception {
     Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
 
@@ -795,6 +946,173 @@
   }
 
   @Test
+  public void newPatchsetCanHaveDifferentUploader() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo currentPatchsetRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(changeOwner.get());
+
+    Account.Id newUploader = accountOperations.newAccount().create();
+    changeOperations.change(changeId).newPatchset().uploader(newUploader).create();
+
+    change = getChangeFromServer(changeId);
+    currentPatchsetRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(newUploader.get());
+  }
+
+  @Test
+  public void createdPatchsetPreviousAuthorAsAuthor() throws Exception {
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    Change.Id changeId = changeOperations.newChange().author(author).create();
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+
+    changeOperations.change(changeId).newPatchset().create();
+    change = getChangeFromServer(changeId);
+    revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    changeOperations.change(changeId).newPatchset().author(author).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(author.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedAuthorIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+    changeOperations.change(changeId).newPatchset().authorIdent(authorIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+    assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+  }
+
+  @Test
+  public void patchsetCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    Account.Id author = accountOperations.newAccount().create();
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .change(changeId)
+                    .newPatchset()
+                    .author(author)
+                    .authorIdent(authorIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("author and authorIdent cannot be set together");
+  }
+
+  @Test
+  public void createdPatchsetPreviousCommitterAsCommitter() throws Exception {
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().committer(committer).create();
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+
+    changeOperations.change(changeId).newPatchset().create();
+    change = getChangeFromServer(changeId);
+    revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedCommitter() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    changeOperations.change(changeId).newPatchset().committer(committer).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedCommitterIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+    changeOperations.change(changeId).newPatchset().committerIdent(committerIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+    assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+  }
+
+  @Test
+  public void patchsetCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    Account.Id committer = accountOperations.newAccount().create();
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .change(changeId)
+                    .newPatchset()
+                    .committer(committer)
+                    .committerIdent(committerIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer and committerIdent cannot be set together");
+  }
+
+  @Test
   public void newPatchsetCanHaveUpdatedCommitMessage() throws Exception {
     Change.Id changeId = changeOperations.newChange().commitMessage("Old message").create();
 
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 3a534e9..447b625 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -42,6 +42,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
@@ -59,6 +60,7 @@
             .setCommitId(
                 Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setRealUploaderAccountId(Entities.Account_Id.newBuilder().setId(687))
             .setCreatedOn(930349320L)
             .setGroups("group1, group2")
             .setPushCertificate("my push certificate")
@@ -74,6 +76,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
@@ -88,6 +91,7 @@
             .setCommitId(
                 Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setRealUploaderAccountId(Entities.Account_Id.newBuilder().setId(687))
             .setCreatedOn(930349320L)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
@@ -100,6 +104,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
@@ -118,6 +123,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
@@ -143,6 +149,30 @@
                 .id(PatchSet.id(Change.id(103), 73))
                 .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
                 .uploader(Account.id(0))
+                .realUploader(Account.id(0))
+                .createdOn(Instant.EPOCH)
+                .build());
+  }
+
+  @Test
+  public void realUploaderIsSetToUploaderIfMissingFromProto() {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .build();
+
+    PatchSet convertedPatchSet = patchSetProtoConverter.fromProto(proto);
+    Truth.assertThat(convertedPatchSet)
+        .isEqualTo(
+            PatchSet.builder()
+                .id(PatchSet.id(Change.id(103), 73))
+                .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
+                .uploader(Account.id(452))
+                .realUploader(Account.id(452))
                 .createdOn(Instant.EPOCH)
                 .build());
   }
@@ -156,6 +186,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
+                .put("realUploader", Account.Id.class)
                 .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index f45d33b..8a7d25a 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -294,6 +294,7 @@
     assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
     assertThat(diff.added().revisions.get(REVISION).uploader.email)
         .isEqualTo(newRevision.uploader.email);
+    assertThat(diff.added().revisions.get(REVISION).realUploader).isNull();
     assertThat(diff.removed().revisions).isNotNull();
     assertThat(diff.removed().revisions).hasSize(1);
     assertThat(diff.removed().revisions).containsKey(REVISION);
@@ -301,6 +302,37 @@
     assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
     assertThat(diff.removed().revisions.get(REVISION).uploader.email)
         .isEqualTo(oldRevision.uploader.email);
+    assertThat(diff.removed().revisions.get(REVISION).realUploader).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionRealUploader() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("uploader", "uploader@mail.com"));
+    oldRevision.realUploader = new AccountInfo("real-uploader", "real-uploader@mail.com");
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+    newRevision.realUploader =
+        new AccountInfo(oldRevision.realUploader.name, oldRevision.realUploader.email + "2");
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).uploader).isNull();
+    assertThat(diff.added().revisions.get(REVISION).realUploader).isNotNull();
+    assertThat(diff.added().revisions.get(REVISION).realUploader.name).isNull();
+    assertThat(diff.added().revisions.get(REVISION).realUploader.email)
+        .isEqualTo(newRevision.realUploader.email);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).uploader).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).realUploader).isNotNull();
+    assertThat(diff.removed().revisions.get(REVISION).realUploader.name).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).realUploader.email)
+        .isEqualTo(oldRevision.realUploader.email);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index f65e823..06ea8b6 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -60,15 +60,13 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    // Pick any known experiment enabled by default;
-    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
-    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).isEmpty();
 
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
-        "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+        "experiments", null, "disabled", ImmutableList.of("DisabledFeature"));
     ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
         new IndexServlet(
@@ -97,7 +95,6 @@
                 + "\\x5b\\x5d\\x7d');");
     ImmutableSet<String> enabledDefaults =
         ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
-            .filter(e -> !e.equals(disabledDefault))
             .collect(ImmutableSet.toImmutableSet());
     List<String> expectedEnabled = new ArrayList<>();
     expectedEnabled.add("NewFeature");
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 855a0bc..30ae4aa 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -92,6 +92,7 @@
             bind(AccountCache.class).toInstance(accountCache);
             bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
             bind(Realm.class).toInstance(mockRealm);
+            install(new DefaultRefLogIdentityProvider.Module());
           }
         };
 
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index fbeabe1..7f893f1 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.template.soy.shared.SoyAstCache;
 import java.nio.file.Paths;
 import org.junit.Before;
 import org.junit.Test;
@@ -40,9 +39,7 @@
   public void soyCompilation() {
     MailSoySauceLoader loader =
         new MailSoySauceLoader(
-            sitePaths,
-            new SoyAstCache(),
-            new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
+            sitePaths, new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
     assertThat(loader.load()).isNotNull(); // should not throw
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index be8f1f9..1e6ba3a 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -180,6 +181,7 @@
 
             install(new DefaultUrlFormatterModule());
             install(NoteDbModule.forTest());
+            install(new DefaultRefLogIdentityProvider.Module());
             bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
             bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
             bind(new TypeLiteral<ImmutableSet<String>>() {})
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 976ffc8..c654828 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -342,22 +342,24 @@
             .id(PatchSet.id(ID, 1))
             .commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
             .uploader(Account.id(2000))
+            .realUploader(Account.id(2001))
             .createdOn(cols.createdOn())
             .build();
     Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
     ByteString ps1Bytes = Protos.toByteString(ps1Proto);
-    assertThat(ps1Bytes.size()).isEqualTo(66);
+    assertThat(ps1Bytes.size()).isEqualTo(71);
 
     PatchSet ps2 =
         PatchSet.builder()
             .id(PatchSet.id(ID, 2))
             .commitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
             .uploader(Account.id(3000))
+            .realUploader(Account.id(3001))
             .createdOn(cols.lastUpdatedOn())
             .build();
     Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
     ByteString ps2Bytes = Protos.toByteString(ps2Proto);
-    assertThat(ps2Bytes.size()).isEqualTo(66);
+    assertThat(ps2Bytes.size()).isEqualTo(71);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
     assertRoundTrip(
@@ -1018,6 +1020,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
+                .put("realUploader", Account.Id.class)
                 .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 61b5e55..9cd002e 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -825,7 +825,8 @@
         ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
     for (String strangeTag : strangeTags) {
       Change c = newChange();
-      CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+      CurrentUser otherUserAsOwner =
+          userFactory.runAs(/* remotePeer= */ null, changeOwner.getAccountId(), otherUser);
       ChangeUpdate update = newUpdate(c, otherUserAsOwner);
       update.putApproval(LabelId.CODE_REVIEW, (short) 2);
       update.setTag(strangeTag);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index b53de89..25f2f98 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -354,7 +354,8 @@
   @Test
   public void realUser() throws Exception {
     Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(/* remotePeer= */ null, otherUserId, changeOwner);
     ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
     update.setChangeMessage("Message on behalf of other user");
     update.commit();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 5e6803e..527e78e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -399,7 +399,9 @@
 
     IdentifiedUser impersonatedChangeOwner =
         this.userFactory.runAs(
-            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+            /* remotePeer= */ null,
+            changeOwner.getAccountId(),
+            requireNonNull(otherUser).getRealUser());
     ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
     impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
     impersonatedChangeMessageUpdate.commit();
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index e48d4af..f80a96d 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -46,6 +46,7 @@
         .id(PatchSet.id(changeId, num))
         .commitId(ObjectId.zeroId())
         .uploader(Account.id(1234))
+        .realUploader(Account.id(5678))
         .createdOn(TimeUtil.now())
         .build();
   }
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 4c8750a..131bd05 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -239,6 +239,7 @@
         .id(id)
         .commitId(dummyObjectId)
         .uploader(Account.id(123))
+        .realUploader(Account.id(456))
         .createdOn(Instant.ofEpochMilli(12345))
         .build();
   }
diff --git a/modules/jgit b/modules/jgit
index 801a56b..66b871b 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 801a56b48a7fe3c6e171073211cc62194184fe79
+Subproject commit 66b871b777c1a58337e80dd03db68bb76b145a93
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index ce9ce2d..e3b3ad4 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -201,7 +201,7 @@
   syntax_highlighting?: boolean;
   tab_size: number;
   font_size: number;
-  // TODO: Missing documentation
+  // Hides the FILE and LOST diff rows. Default is TRUE.
   show_file_comment_button?: boolean;
   line_wrapping?: boolean;
 }
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 89c7622..f915432 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -319,6 +319,4 @@
 
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
-export const SHOWN_ITEMS_COUNT = 25;
-
 export const WAITING = 'Waiting';
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index d3562f2..893a997 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -10,7 +10,6 @@
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -43,21 +42,19 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  @state() private hasNewGroupName = false;
+  @state() hasNewGroupName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() groups: GroupInfo[] = [];
 
-  @state() private groupsPerPage = 25;
+  @state() groupsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -108,7 +105,7 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.groups
-              .slice(0, SHOWN_ITEMS_COUNT)
+              .slice(0, this.groupsPerPage)
               .map(group => this.renderGroupList(group))}
           </tbody>
         </table>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index e9b7ea0..fe5aa22 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -15,7 +15,6 @@
 import {GerritView} from '../../../services/router/router-model';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -117,7 +116,9 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
 
     test('maybeOpenCreateModal', async () => {
@@ -145,7 +146,9 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 8d5689c..96688e9 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -6,7 +6,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {page} from '../../../utils/page-wrapper-utils';
 import {GroupId, GroupName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -16,6 +15,8 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
 import {createGroupUrl} from '../../../models/views/group';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,6 +33,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   static override get styles() {
     return [
       formStyles,
@@ -86,8 +89,7 @@
       return this.restApiService.getGroupConfig(name).then(group => {
         if (!group) return;
         const groupId = String(group.group_id!) as GroupId;
-        // TODO: Use navigation service instead of `page.show()` directly.
-        page.show(createGroupUrl({groupId}));
+        this.getNavigation().setUrl(createGroupUrl({groupId}));
       });
     });
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
index 2a0b539..6e36d8c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-create-group-dialog';
 import {GrCreateGroupDialog} from './gr-create-group-dialog';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   mockPromise,
   queryAndAssert,
@@ -15,6 +14,8 @@
 import {IronInputElement} from '@polymer/iron-input';
 import {GroupId} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-create-group-dialog tests', () => {
   let element: GrCreateGroupDialog;
@@ -68,9 +69,9 @@
       Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
     );
 
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     await element.handleCreateGroup();
-    assert.isTrue(showStub.calledWith('/admin/groups/551'));
+    assert.isTrue(setUrlStub.calledWith('/admin/groups/551'));
   });
 
   test('test for unsuccessful group creation', async () => {
@@ -81,8 +82,8 @@
       Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
     );
 
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     await element.handleCreateGroup();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index bb59ccc..ed57830 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -7,7 +7,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   BranchName,
   GroupId,
@@ -24,6 +23,8 @@
 import {fireEvent} from '../../../utils/event-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {createRepoUrl} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -71,6 +72,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoSuggestions(input);
@@ -195,8 +198,7 @@
     );
     if (repoRegistered.status === 201) {
       this.repoCreated = true;
-      // TODO: Use navigation service instead of `page.show()` directly.
-      page.show(createRepoUrl({repo: this.repoConfig.name}));
+      this.getNavigation().setUrl(createRepoUrl({repo: this.repoConfig.name}));
     }
     return repoRegistered;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 383b4a7..a2de840 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -9,7 +9,6 @@
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -34,17 +33,15 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() plugins?: PluginInfoWithName[];
 
-  @state() private pluginsPerPage = 25;
+  @state() pluginsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -107,7 +104,7 @@
     return html`
       <tbody>
         ${this.plugins
-          ?.slice(0, SHOWN_ITEMS_COUNT)
+          ?.slice(0, this.pluginsPerPage)
           .map(plugin => this.renderPluginList(plugin))}
       </tbody>
     `;
@@ -176,7 +173,7 @@
   }
 
   private computePluginUrl(id: string) {
-    return getBaseUrl() + '/' + encodeURL(id, true);
+    return getBaseUrl() + '/' + encodeURL(id);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
index 4057e52..34c88be 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -17,7 +17,6 @@
 import {PluginInfo} from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {PageErrorEvent} from '../../../types/events';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -334,7 +333,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
@@ -348,7 +349,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
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 7eef7a4..41ad3f8 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
@@ -25,7 +25,6 @@
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -51,36 +50,30 @@
   @property({type: Object})
   params?: RepoViewState;
 
-  // private but used in test
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  // private but used in test
   @state() isOwner = false;
 
-  @state() private loggedIn = false;
+  @state() loggedIn = false;
 
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() repo?: RepoName;
 
-  // private but used in test
   @state() items?: BranchInfo[] | TagInfo[];
 
-  @state() private readonly itemsPerPage = 25;
+  @state() readonly itemsPerPage = 25;
 
-  @state() private loading = true;
+  @state() loading = true;
 
-  @state() private filter?: string;
+  @state() filter?: string;
 
-  @state() private refName?: GitRef;
+  @state() refName?: GitRef;
 
-  @state() private newItemName = false;
+  @state() newItemName = false;
 
-  // private but used in test
   @state() isEditing = false;
 
-  // private but used in test
   @state() revisedRef?: GitRef;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -185,7 +178,7 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.items
-              ?.slice(0, SHOWN_ITEMS_COUNT)
+              ?.slice(0, this.itemsPerPage)
               .map((item, index) => this.renderItemList(item, index))}
           </tbody>
         </table>
@@ -445,7 +438,7 @@
     // 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 ?? '', false)},${detailType}`;
+    return `/admin/repos/${encodeURL(repo ?? '')},${detailType}`;
   }
 
   private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index ec1bb82..9a67fda 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-repo-detail-list';
 import {GrRepoDetailList} from './gr-repo-detail-list';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   addListenerForTest,
   mockPromise,
@@ -32,9 +31,10 @@
 import {PageErrorEvent} from '../../../types/events';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function branchGenerator(counter: number) {
   return {
@@ -96,7 +96,7 @@
         html`<gr-repo-detail-list></gr-repo-detail-list>`
       );
       element.detailType = RepoDetailView.BRANCHES;
-      sinon.stub(page, 'show');
+      sinon.stub(testResolver(navigationToken), 'setUrl');
     });
 
     suite('list of repo branches', () => {
@@ -2339,7 +2339,7 @@
         html`<gr-repo-detail-list></gr-repo-detail-list>`
       );
       element.detailType = RepoDetailView.TAGS;
-      sinon.stub(page, 'show');
+      sinon.stub(testResolver(navigationToken), 'setUrl');
     });
 
     suite('list of repo tags', () => {
@@ -2391,7 +2391,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
@@ -2411,7 +2413,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 2fd7e79..055cb30 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -8,7 +8,7 @@
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectInfoWithName, WebLinkInfo} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {tableStyles} from '../../../styles/gr-table-styles';
@@ -39,23 +39,18 @@
   @property({type: Object})
   params?: AdminViewState;
 
-  // private but used in test
   @state() offset = 0;
 
-  @state() private newRepoName = false;
+  @state() newRepoName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() repos: ProjectInfoWithName[] = [];
 
-  // private but used in test
   @state() reposPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
@@ -147,7 +142,7 @@
   }
 
   private renderRepoList() {
-    const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+    const shownRepos = this.repos.slice(0, this.reposPerPage);
     return shownRepos.map(item => this.renderRepo(item));
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 5b65942..906b733 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   mockPromise,
   queryAndAssert,
@@ -17,12 +16,14 @@
   ProjectInfoWithName,
   RepoName,
 } from '../../../types/common';
-import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../api/rest-api';
 import {GerritView} from '../../../services/router/router-model';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function createRepo(name: string, counter: number) {
   return {
@@ -51,7 +52,7 @@
   let repos: ProjectInfoWithName[];
 
   setup(async () => {
-    sinon.stub(page, 'show');
+    sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture(html`<gr-repo-list></gr-repo-list>`);
   });
 
@@ -614,7 +615,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
 
     test('maybeOpenCreateModal', () => {
@@ -645,7 +648,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 8d6d89a..3599224 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -25,7 +25,7 @@
   RepoState,
   SubmitType,
 } from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
@@ -36,8 +36,9 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
 import {deepClone} from '../../../utils/deep-util';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
 import {userModelToken} from '../../../models/user/user-model';
@@ -150,16 +151,6 @@
           color: var(--deemphasized-text-color);
           content: ' *';
         }
-        .loading,
-        .hide {
-          display: none;
-        }
-        #loading.loading {
-          display: block;
-        }
-        #loading:not(.loading) {
-          display: none;
-        }
         #options .repositorySettings {
           display: none;
         }
@@ -187,49 +178,48 @@
             >
           </div>
         </div>
-        <div id="loading" class=${this.loading ? 'loading' : ''}>
-          Loading...
-        </div>
-        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
-          ${this.renderDownloadCommands()}
-          <h2
-            id="configurations"
-            class="heading-2 ${configChanged ? 'edited' : ''}"
-          >
-            Configurations
-          </h2>
-          <div id="form">
-            <fieldset>
-              ${this.renderDescription()} ${this.renderRepoOptions()}
-              ${this.renderPluginConfig()}
-              <gr-button
-                ?disabled=${this.readOnly || !configChanged}
-                @click=${this.handleSaveRepoConfig}
-                >Save changes</gr-button
-              >
-            </fieldset>
-            <gr-endpoint-decorator name="repo-config">
-              <gr-endpoint-param
-                name="repoName"
-                .value=${this.repo}
-              ></gr-endpoint-param>
-              <gr-endpoint-param
-                name="readOnly"
-                .value=${this.readOnly}
-              ></gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </div>
-        </div>
+        ${when(
+          this.loading || !this.repoConfig,
+          () => html`<div id="loading">Loading...</div>`,
+          () => html`<div id="loadedContent">
+            ${this.renderDownloadCommands()}
+            <h2
+              id="configurations"
+              class="heading-2 ${configChanged ? 'edited' : ''}"
+            >
+              Configurations
+            </h2>
+            <div id="form">
+              <fieldset>
+                ${this.renderDescription()} ${this.renderRepoOptions()}
+                ${this.renderPluginConfig()}
+                <gr-button
+                  ?disabled=${this.readOnly || !configChanged}
+                  @click=${this.handleSaveRepoConfig}
+                  >Save changes</gr-button
+                >
+              </fieldset>
+              <gr-endpoint-decorator name="repo-config">
+                <gr-endpoint-param
+                  name="repoName"
+                  .value=${this.repo}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="readOnly"
+                  .value=${this.readOnly}
+                ></gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>`
+        )}
       </div>
     `;
   }
 
   private renderDownloadCommands() {
+    if (!this.schemes.length) return nothing;
     return html`
-      <div
-        id="downloadContent"
-        class=${!this.schemes || !this.schemes.length ? 'hide' : ''}
-      >
+      <div id="downloadContent">
         <h2 id="download" class="heading-2">Download</h2>
         <fieldset>
           <gr-download-commands
@@ -252,6 +242,7 @@
   }
 
   private renderDescription() {
+    assertIsDefined(this.repoConfig, 'repoConfig');
     return html`
       <h3 id="Description" class="heading-3">Description</h3>
       <fieldset>
@@ -263,7 +254,7 @@
           rows="4"
           monospace
           ?disabled=${this.readOnly}
-          .text=${this.repoConfig?.description ?? ''}
+          .text=${this.repoConfig.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
         ></gr-textarea>
       </fieldset>
@@ -725,8 +716,9 @@
 
   private renderPluginConfig() {
     const pluginData = this.computePluginData();
+    if (!pluginData.length) return nothing;
     return html` <div
-      class="pluginConfig ${!pluginData || !pluginData.length ? 'hide' : ''}"
+      class="pluginConfig"
       @plugin-config-changed=${this.handlePluginConfigChanged}
     >
       <h3 class="heading-3">Plugins</h3>
@@ -762,6 +754,12 @@
   // private but used in test
   async loadRepo() {
     if (!this.repo) return Promise.resolve();
+    this.repoConfig = undefined;
+    this.originalConfig = undefined;
+    this.loading = true;
+    this.weblinks = [];
+    this.schemesObj = undefined;
+    this.readOnly = true;
 
     const promises = [];
 
@@ -1121,6 +1119,7 @@
 
   private handleDescriptionTextChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.description === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       description: e.detail.value,
@@ -1130,6 +1129,7 @@
 
   private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.state === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       state: e.detail.value as RepoState,
@@ -1139,6 +1139,7 @@
 
   private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.submit_type === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       submit_type: e.detail.value as SubmitType,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index c013c9e..4deb99a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -157,14 +157,17 @@
     element = await fixture(html`<gr-repo></gr-repo>`);
   });
 
-  test('render', () => {
+  test('render', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
     // prettier and shadowDom assert do not agree about span.title wrapping
     assert.shadowDom.equal(
       element,
       /* prettier-ignore */ /* HTML */ `
       <div class="gr-form-styles main read-only">
         <div class="info">
-          <h1 class="heading-1" id="Title"></h1>
+          <h1 class="heading-1" id="Title">test-repo</h1>
           <hr />
           <div>
             <a href="">
@@ -178,7 +181,7 @@
                 Browse
               </gr-button>
             </a>
-            <a href="">
+            <a href="/q/project:test-repo">
               <gr-button
                 aria-disabled="false"
                 link=""
@@ -190,15 +193,7 @@
             </a>
           </div>
         </div>
-        <div class="loading" id="loading">Loading...</div>
-        <div class="loading" id="loadedContent">
-          <div class="hide" id="downloadContent">
-            <h2 class="heading-2" id="download">Download</h2>
-            <fieldset>
-              <gr-download-commands id="downloadCommands">
-              </gr-download-commands>
-            </fieldset>
-          </div>
+        <div id="loadedContent">
           <h2 class="heading-2" id="configurations">Configurations</h2>
           <div id="form">
             <fieldset>
@@ -266,7 +261,7 @@
                   </span>
                 </section>
                 <section
-                  class="repositorySettings"
+                  class="repositorySettings showConfig"
                   id="enableSignedPushSettings"
                 >
                   <span class="title"> Enable signed push </span>
@@ -277,7 +272,7 @@
                   </span>
                 </section>
                 <section
-                  class="repositorySettings"
+                  class="repositorySettings showConfig"
                   id="requireSignedPushSettings"
                 >
                   <span class="title"> Require signed push </span>
@@ -379,9 +374,6 @@
                   </span>
                 </section>
               </fieldset>
-              <div class="hide pluginConfig">
-                <h3 class="heading-3">Plugins</h3>
-              </div>
               <gr-button
                 aria-disabled="true"
                 disabled=""
@@ -398,7 +390,51 @@
           </div>
         </div>
       </div>
-    `
+    `,
+      {ignoreTags: ['option']}
+    );
+  });
+
+  test('render loading', async () => {
+    element.repo = REPO as RepoName;
+    element.loading = true;
+    await element.updateComplete;
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title">test-repo</h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="/q/project:test-repo">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div id="loading">Loading...</div>
+      </div>
+    `,
+      {ignoreTags: ['option']}
     );
   });
 
@@ -451,55 +487,22 @@
     assert.isTrue(requestUpdateStub.called);
   });
 
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
-        'loading'
-      )
-    );
-    assert.isFalse(
-      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
-        .display === 'none'
-    );
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#loadedContent'
-      ).classList.contains('loading')
-    );
-    assert.isTrue(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
-      ).display === 'none'
-    );
-  });
-
-  test('download commands visibility', async () => {
-    element.loading = false;
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#downloadContent'
-      ).classList.contains('hide')
-    );
-    assert.isTrue(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
-      ).display === 'none'
-    );
+  test('render download commands', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
     element.schemesObj = SCHEMES;
     await element.updateComplete;
-    assert.isFalse(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#downloadContent'
-      ).classList.contains('hide')
-    );
-    assert.isFalse(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
-      ).display === 'none'
+    const content = queryAndAssert<HTMLDivElement>(element, '#downloadContent');
+    assert.dom.equal(
+      content,
+      /* HTML */ `
+        <div id="downloadContent">
+          <h2 class="heading-2" id="download">Download</h2>
+          <fieldset>
+            <gr-download-commands id="downloadCommands"></gr-download-commands>
+          </fieldset>
+        </div>
+      `
     );
   });
 
@@ -715,9 +718,9 @@
         Promise.resolve(new Response())
       );
 
-      const button = queryAll<GrButton>(element, 'gr-button')[2];
-
       await element.loadRepo();
+
+      const button = queryAll<GrButton>(element, 'gr-button')[2];
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 975dd3b..5f0a171 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -343,7 +343,7 @@
   // private but used in test
   computeGroupPath(groupId?: string) {
     if (!groupId) return;
-    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId)}`;
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index faaee0b..d2ba2c9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -6,7 +6,6 @@
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   AccountDetailInfo,
   AccountId,
@@ -28,6 +27,7 @@
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
 import {userModelToken} from '../../../models/user/user-model';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
@@ -81,6 +81,8 @@
 
   private readonly getViewModel = resolve(this, searchViewModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this.handleNextPage());
@@ -282,15 +284,13 @@
   // private but used in test
   handleNextPage() {
     if (!this.nextArrow || !this.changesPerPage) return;
-    // TODO: Use navigation service instead of `page.show()` directly.
-    page.show(this.computeNavLink(1));
+    this.getNavigation().setUrl(this.computeNavLink(1));
   }
 
   // private but used in test
   handlePreviousPage() {
     if (!this.prevArrow || !this.changesPerPage) return;
-    // TODO: Use navigation service instead of `page.show()` directly.
-    page.show(this.computeNavLink(-1));
+    this.getNavigation().setUrl(this.computeNavLink(-1));
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index f4bd8bd..decc253 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-change-list-view';
 import {GrChangeListView} from './gr-change-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
 import {query, queryAndAssert} from '../../../test/test-utils';
 import {createChange} from '../../../test/test-data-generators';
 import {ChangeInfo} from '../../../api/rest-api';
@@ -14,6 +13,8 @@
 import {GrChangeList} from '../gr-change-list/gr-change-list';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-change-list-view tests', () => {
   let element: GrChangeListView;
@@ -158,7 +159,7 @@
   });
 
   test('handleNextPage', async () => {
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.changes = Array(25)
       .fill(0)
       .map(_ => createChange());
@@ -166,7 +167,7 @@
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
 
     element.changes = Array(25)
       .fill(0)
@@ -174,11 +175,11 @@
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
-    assert.isTrue(showStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 
   test('handlePreviousPage', async () => {
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.offset = 0;
     element.changes = Array(25)
       .fill(0)
@@ -187,11 +188,11 @@
     element.loading = false;
     await element.updateComplete;
     element.handlePreviousPage();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
 
     element.offset = 25;
     await element.updateComplete;
     element.handlePreviousPage();
-    assert.isTrue(showStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 84bdffb..b726292 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -55,7 +55,6 @@
 
 import {SummaryChipStyles} from './gr-summary-chip';
 import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {combineLatest} from 'rxjs';
 import {userModelToken} from '../../../models/user/user-model';
 
@@ -120,8 +119,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     subscribe(
@@ -174,24 +171,22 @@
       () => this.getUserModel().account$,
       x => (this.selfAccount = x)
     );
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      subscribe(
-        this,
-        () =>
-          combineLatest([
-            this.getUserModel().account$,
-            this.getCommentsModel().threads$,
-          ]),
-        ([selfAccount, threads]) => {
-          if (!selfAccount || !selfAccount.email) return;
-          const unresolvedThreadsMentioningSelf = getMentionedThreads(
-            threads,
-            selfAccount
-          ).filter(isUnresolved);
-          this.mentionCount = unresolvedThreadsMentioningSelf.length;
-        }
-      );
-    }
+    subscribe(
+      this,
+      () =>
+        combineLatest([
+          this.getUserModel().account$,
+          this.getCommentsModel().threads$,
+        ]),
+      ([selfAccount, threads]) => {
+        if (!selfAccount || !selfAccount.email) return;
+        const unresolvedThreadsMentioningSelf = getMentionedThreads(
+          threads,
+          selfAccount
+        ).filter(isUnresolved);
+        this.mentionCount = unresolvedThreadsMentioningSelf.length;
+      }
+    );
   }
 
   static override get styles() {
@@ -575,8 +570,6 @@
   }
 
   private renderMentionChip() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     if (!this.mentionCount) return nothing;
     return html` <gr-summary-chip
       class="mentionSummary"
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 c0ed3b3..d4627e2 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
@@ -1256,7 +1256,14 @@
         flatten
         down-arrow
         class="showCopyLinkDialogButton"
-        @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+        @click=${(e: MouseEvent) => {
+          // We don't want to handle clicks on the star or the <a> link.
+          // Calling `stopPropagation()` from the click handler of <a> is not an
+          // option, because then the click does not reach the top-level page.js
+          // click handler and would result is a full page reload.
+          if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
+          this.copyLinksDropdown?.toggleDropdown();
+        }}
         ><gr-change-star
           id="changeStar"
           .change=${this.change}
@@ -1267,10 +1274,7 @@
         <a
           class="changeNumber"
           aria-label=${`Change ${this.change?._number}`}
-          @click=${(e: MouseEvent) => {
-            fireReload(this, true);
-            e.stopPropagation();
-          }}
+          href=${ifDefined(this.computeChangeUrl(true))}
           >${this.change?._number}</a
         >
       </gr-button>
@@ -2075,9 +2079,7 @@
 
   // Private but used in tests.
   viewStateChanged() {
-    // viewState is set by gr-router in handleChangeRoute method and is never
-    // set to undefined
-    assertIsDefined(this.viewState, 'viewState');
+    if (!this.viewState) return;
 
     if (this.isChangeObsolete()) {
       // Tell the app element that we are not going to handle the new change
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index b0dbda5..6f8bd9a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -71,6 +71,9 @@
   text = '';
 
   @state()
+  shouldRebaseChain = false;
+
+  @state()
   private query: AutocompleteQuery;
 
   @state()
@@ -184,7 +187,7 @@
             />
             <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
               Rebase on top of the ${this.branch} branch<span
-                ?hidden=${!this.hasParent}
+                ?hidden=${!this.hasParent || this.shouldRebaseChain}
               >
                 (breaks relation chain)
               </span>
@@ -206,7 +209,9 @@
             />
             <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
               Rebase on a specific change, ref, or commit
-              <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+              <span ?hidden=${!this.hasParent || this.shouldRebaseChain}>
+                (breaks relation chain)
+              </span>
             </label>
           </div>
           <div class="parentRevisionContainer">
@@ -230,10 +235,17 @@
             >
           </div>
           ${when(
-            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN),
+            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN) &&
+              this.hasParent,
             () =>
               html`<div>
-                <input id="rebaseChain" type="checkbox" />
+                <input
+                  id="rebaseChain"
+                  type="checkbox"
+                  @change=${() => {
+                    this.shouldRebaseChain = !!this.rebaseChain?.checked;
+                  }}
+                />
                 <label for="rebaseChain">Rebase all ancestors</label>
               </div>`
           )}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index d4cff78..218594b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -121,7 +121,6 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {
   CommentEditingChangedDetail,
@@ -384,8 +383,6 @@
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -671,9 +668,6 @@
       this,
       () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
       x => {
-        if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-          return;
-        }
         this.mentionedUsersInUnresolvedDrafts = x.filter(
           v => !this.isAlreadyReviewerOrCC(v)
         );
@@ -1441,18 +1435,13 @@
     ).filter(isDefined);
 
     for (const user of newAttentionSetUsers) {
-      let reason;
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        reason =
-          getMentionedReason(
-            this.draftCommentThreads,
-            this.account,
-            user,
-            this.serverConfig
-          ) ?? '';
-      } else {
-        reason = getReplyByReason(this.account, this.serverConfig);
-      }
+      const reason =
+        getMentionedReason(
+          this.draftCommentThreads,
+          this.account,
+          user,
+          this.serverConfig
+        ) ?? '';
       reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
     }
     reviewInput.remove_from_attention_set = [];
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 12b1c40..f7b3aec 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -13,7 +13,6 @@
   query,
   queryAll,
   queryAndAssert,
-  stubFlags,
   stubRestApi,
   waitUntilVisible,
 } from '../../../test/test-utils';
@@ -59,7 +58,6 @@
 import {accountKey} from '../../../utils/account-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {Key, Modifier} from '../../../utils/dom-util';
 import {GrComment} from '../../shared/gr-comment/gr-comment';
 import {testResolver} from '../../../test/common-test-setup';
@@ -2527,9 +2525,6 @@
 
   suite('mention users', () => {
     setup(async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.account = createAccountWithId(1);
       element.requestUpdate();
       await element.updateComplete;
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 80a1a9a..c09d2a2 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,7 +42,6 @@
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {HtmlPatched} from '../../../utils/lit-util';
 import {userModelToken} from '../../../models/user/user-model';
 import {specialFilePathCompare} from '../../../utils/path-list-util';
@@ -205,8 +204,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly patched = new HtmlPatched(key => {
@@ -495,14 +492,10 @@
       value: CommentTabState.UNRESOLVED,
     });
     if (this.account) {
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        items.push({
-          text: `Mentions (${
-            getMentionedThreads(threads, this.account).length
-          })`,
-          value: CommentTabState.MENTIONS,
-        });
-      }
+      items.push({
+        text: `Mentions (${getMentionedThreads(threads, this.account).length})`,
+        value: CommentTabState.MENTIONS,
+      });
       items.push({
         text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 0a21da4..efc6efe 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -8,11 +8,21 @@
 import {LitElement, css, html, PropertyValues, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
-import {createFixAction, iconFor} from '../../models/checks/checks-util';
+import {
+  createFixAction,
+  createPleaseFixComment,
+  iconFor,
+} from '../../models/checks/checks-util';
 import {modifierPressed} from '../../utils/dom-util';
 import './gr-checks-results';
 import './gr-hovercard-run';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {Action} from '../../api/checks';
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {subscribe} from '../lit/subscription-controller';
+import {changeModelToken} from '../../models/change/change-model';
 
 @customElement('gr-diff-check-result')
 export class GrDiffCheckResult extends LitElement {
@@ -32,6 +42,13 @@
   @state()
   isExpandable = false;
 
+  @state()
+  isOwner = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
   static override get styles() {
     return [
       fontStyles,
@@ -114,6 +131,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+  }
+
   override render() {
     if (!this.result) return;
     const cat = this.result.category.toLowerCase();
@@ -182,14 +208,39 @@
 
   private renderActions() {
     if (!this.isExpanded) return nothing;
-    return html`<div class="actions">${this.renderFixButton()}</div>`;
+    return html`<div class="actions">
+      ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+    </div>`;
   }
 
-  private renderFixButton() {
+  private renderPleaseFixButton() {
+    if (this.isOwner) return nothing;
+    const action: Action = {
+      name: 'Please Fix',
+      callback: () => {
+        assertIsDefined(this.result, 'result');
+        this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
+        return undefined;
+      },
+    };
+    return html`
+      <gr-checks-action
+        id="please-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
+    `;
+  }
+
+  private renderShowFixButton() {
     const action = createFixAction(this, this.result);
     if (!action) return nothing;
     return html`
-      <gr-checks-action context="diff-fix" .action=${action}></gr-checks-action>
+      <gr-checks-action
+        id="show-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 3892c9a..0377e0e 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -7,6 +7,7 @@
 import {fakeRun1} from '../../models/checks/checks-fakes';
 import {RunResult} from '../../models/checks/checks-model';
 import '../../test/common-test-setup';
+import {queryAndAssert} from '../../utils/common-util';
 import './gr-diff-check-result';
 import {GrDiffCheckResult} from './gr-diff-check-result';
 
@@ -50,4 +51,30 @@
     `
     );
   });
+
+  test('renders expanded', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+    element.isExpanded = true;
+    await element.updateComplete;
+
+    const details = queryAndAssert(element, 'div.details');
+    assert.dom.equal(
+      details,
+      /* HTML */ `
+        <div class="details">
+          <gr-result-expanded hidecodepointers=""></gr-result-expanded>
+          <div class="actions">
+            <gr-checks-action
+              id="please-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+            <gr-checks-action
+              id="show-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index bcf6937..c0433dd 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
+  Options,
   page,
   PageContext,
   PageNextCallback,
@@ -34,6 +35,7 @@
 import {fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
+  encodeURL,
   getBaseUrl,
   PatchRangeParams,
   toPath,
@@ -49,6 +51,7 @@
   AdminChildView,
   AdminViewModel,
   AdminViewState,
+  PLUGIN_LIST_ROUTE,
 } from '../../../models/views/admin';
 import {
   AgreementViewModel,
@@ -99,7 +102,11 @@
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
 import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {Route, ViewState} from '../../../models/views/base';
+import {Model} from '../../../models/model';
 
+// TODO: Move all patterns to view model files and use the `Route` interface,
+// which will enforce using `RegExp` in its `urlPattern` property.
 const RoutePattern = {
   ROOT: '/',
 
@@ -114,7 +121,9 @@
 
   // Pattern for login and logout URLs intended to be passed-through. May
   // include a return URL.
-  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+  // TODO: Maybe this pattern and its handler can just be removed, because
+  // passing through is what the default router would eventually do anyway.
+  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
 
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
@@ -135,11 +144,6 @@
   // Matches /admin/groups/[uuid-]<group>,members
   GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
 
-  // Matches /admin/groups[,<offset>][/].
-  GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
-  GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
-  GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
-
   // Matches /admin/create-project
   LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
 
@@ -166,32 +170,26 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  // Matches /admin/repos[,<offset>][/].
-  REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
-  REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
-  REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
-
-  // Matches /admin/repos/<repo>,branches[,<offset>].
-  BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
-  BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
-  BRANCH_LIST_FILTER_OFFSET:
-    '/admin/repos/:repo,branches/q/filter::filter,:offset',
-
-  // Matches /admin/repos/<repo>,tags[,<offset>].
-  TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
-  TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
-  TAG_LIST_FILTER_OFFSET: '/admin/repos/:repo,tags/q/filter::filter,:offset',
-
   PLUGINS: /^\/plugins\/(.+)$/,
 
-  PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+  // TODO: The first capturing group in the next 3 patterns works around a bug
+  // in page.js that is fixed in version 1.11.6. Remove it when the new version
+  // is being used at Google.
+  // https://github.com/visionmedia/page.js/commit/60f764b0ca9ad55133bc373914e97a8927a8f2d5
 
-  // Matches /admin/plugins[,<offset>][/].
-  PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
-  PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
-  PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+  // Matches /admin/plugins with optional filter and offset.
+  PLUGIN_LIST: /^(\/admin\/plugins)\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/groups with optional filter and offset.
+  GROUP_LIST: /^(\/admin\/groups)\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos with optional filter and offset.
+  REPO_LIST: /^(\/admin\/repos)\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos/$REPO,branches with optional filter and offset.
+  BRANCH_LIST:
+    /^\/admin\/repos\/(.+),branches\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos/$REPO,tags with optional filter and offset.
+  TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
 
-  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+  QUERY: /^\/q\/(.+?)(,(\d+))?$/,
 
   /**
    * Support vestigial params from GWT UI.
@@ -251,13 +249,11 @@
 
   PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
 
-  DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+  DOCUMENTATION_SEARCH_FILTER: /^\/Documentation\/q\/filter:(.*)$/,
   DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
   DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
 };
 
-export const _testOnly_RoutePattern = RoutePattern;
-
 /**
  * Pattern to recognize and parse the diff line locations as they appear in
  * the hash of diff URLs. In this format, a number on its own indicates that
@@ -306,6 +302,8 @@
 
   private view?: GerritView;
 
+  readonly page = page.create();
+
   constructor(
     private readonly reporting: ReportingService,
     private readonly routerModel: RouterModel,
@@ -342,7 +340,7 @@
         }
 
         if (browserUrl.toString() !== stateUrl.toString()) {
-          page.replace(
+          this.page.replace(
             stateUrl.toString(),
             null,
             /* init: */ false,
@@ -359,6 +357,7 @@
       subscription.unsubscribe();
     }
     this.subscriptions = [];
+    this.page.stop();
   }
 
   start() {
@@ -369,6 +368,7 @@
   }
 
   setState(state: AppElementParams) {
+    // TODO: Move this logic into the change model.
     if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
       this.restApiService.setInProjectLookup(state.changeNum, state.repo);
 
@@ -400,7 +400,7 @@
 
   redirect(url: string) {
     this._isRedirecting = true;
-    page.redirect(url);
+    this.page.redirect(url);
   }
 
   /**
@@ -429,7 +429,9 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+    this.page(
+      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+    );
   }
 
   /**
@@ -488,6 +490,9 @@
    * route is matched, the handler will be executed with `this` referring
    * to the component. Its return value will be discarded so that it does
    * not interfere with page.js.
+   * TODO: Get rid of this parameter. This is really not something that the
+   * router wants to be concerned with. The reporting service and the view
+   * models should figure that out between themselves.
    * @param authRedirect If true, then auth is checked before
    * executing the handler. If the user is not logged in, it will redirect
    * to the login flow and the handler will not be executed. The login
@@ -499,7 +504,7 @@
     handler: (ctx: PageContext) => void,
     authRedirect?: boolean
   ) {
-    page(
+    this.page(
       pattern,
       (ctx, next) => this.loadUserMiddleware(ctx, next),
       ctx => {
@@ -515,6 +520,32 @@
   }
 
   /**
+   * Convenience wrapper of `mapRoute()` for when you have a `Route` object that
+   * can deal with state creation. Takes care of setting the view model state,
+   * which is currently duplicated lots of times for direct callers of
+   * `mapRoute()`.
+   */
+  mapRouteState<T extends ViewState>(
+    route: Route<T>,
+    viewModel: Model<T | undefined>,
+    handlerName: string,
+    authRedirect?: boolean
+  ) {
+    const handler = (ctx: PageContext) => {
+      const state = route.createState(ctx);
+      // Note that order is important: `this.setState()` must be called before
+      // `viewModel.setState()`. Otherwise the chain of model subscriptions
+      // would be very different. Some views may want app element to swap the
+      // top level view first. Also, `this.setState()` has some special change
+      // view model resetting logic. Eventually the order might not be important
+      // anymore, but be careful! :-)
+      this.setState(state as AppElementParams);
+      viewModel.setState(state);
+    };
+    this.mapRoute(route.urlPattern, handlerName, handler, authRedirect);
+  }
+
+  /**
    * This is similar to letting the browser navigate to this URL when the user
    * clicks it, or to just setting `window.location.href` directly.
    *
@@ -524,14 +555,14 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
-    page.show(url);
+    this.page.show(url);
   }
 
   /**
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
-   * page.redirect() eventually just calls `window.history.replaceState()`.
+   * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
     this.redirect(url);
@@ -551,13 +582,17 @@
     );
   }
 
-  startRouter() {
+  _testOnly_startRouter() {
+    this.startRouter({dispatch: false, popstate: false});
+  }
+
+  startRouter(opts: Options = {}) {
     const base = getBaseUrl();
     if (base) {
-      page.base(base);
+      this.page.base(base);
     }
 
-    page.exit('*', (_, next) => {
+    this.page.exit('*', (_, next) => {
       if (!this._isRedirecting) {
         this.reporting.beforeLocationChanged();
       }
@@ -568,7 +603,7 @@
 
     // Remove the tracking param 'usp' (User Source Parameter) from the URL,
     // just to have users look at cleaner URLs.
-    page((ctx, next) => {
+    this.page((ctx, next) => {
       if (window.URLSearchParams) {
         const pathname = toPathname(ctx.canonicalPath);
         const searchParams = toSearchParams(ctx.canonicalPath);
@@ -584,7 +619,7 @@
     });
 
     // Middleware
-    page((ctx, next) => {
+    this.page((ctx, next) => {
       document.body.scrollTop = 0;
 
       if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
@@ -651,23 +686,9 @@
     );
 
     this.mapRoute(
-      RoutePattern.GROUP_LIST_OFFSET,
-      'handleGroupListOffsetRoute',
-      ctx => this.handleGroupListOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.GROUP_LIST_FILTER_OFFSET,
-      'handleGroupListFilterOffsetRoute',
-      ctx => this.handleGroupListFilterOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.GROUP_LIST_FILTER,
-      'handleGroupListFilterRoute',
-      ctx => this.handleGroupListFilterRoute(ctx),
+      RoutePattern.GROUP_LIST,
+      'handleGroupListRoute',
+      ctx => this.handleGroupListRoute(ctx),
       true
     );
 
@@ -717,40 +738,12 @@
       ctx => this.handleRepoDashboardsRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_OFFSET,
-      'handleBranchListOffsetRoute',
-      ctx => this.handleBranchListOffsetRoute(ctx)
+    this.mapRoute(RoutePattern.BRANCH_LIST, 'handleBranchListRoute', ctx =>
+      this.handleBranchListRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-      'handleBranchListFilterOffsetRoute',
-      ctx => this.handleBranchListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_FILTER,
-      'handleBranchListFilterRoute',
-      ctx => this.handleBranchListFilterRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_OFFSET,
-      'handleTagListOffsetRoute',
-      ctx => this.handleTagListOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_FILTER_OFFSET,
-      'handleTagListFilterOffsetRoute',
-      ctx => this.handleTagListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_FILTER,
-      'handleTagListFilterRoute',
-      ctx => this.handleTagListFilterRoute(ctx)
+    this.mapRoute(RoutePattern.TAG_LIST, 'handleTagListRoute', ctx =>
+      this.handleTagListRoute(ctx)
     );
 
     this.mapRoute(
@@ -767,22 +760,8 @@
       true
     );
 
-    this.mapRoute(
-      RoutePattern.REPO_LIST_OFFSET,
-      'handleRepoListOffsetRoute',
-      ctx => this.handleRepoListOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.REPO_LIST_FILTER_OFFSET,
-      'handleRepoListFilterOffsetRoute',
-      ctx => this.handleRepoListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.REPO_LIST_FILTER,
-      'handleRepoListFilterRoute',
-      ctx => this.handleRepoListFilterRoute(ctx)
+    this.mapRoute(RoutePattern.REPO_LIST, 'handleRepoListRoute', ctx =>
+      this.handleRepoListRoute(ctx)
     );
 
     this.mapRoute(RoutePattern.REPO, 'handleRepoRoute', ctx =>
@@ -794,30 +773,16 @@
     );
 
     this.mapRoute(
-      RoutePattern.PLUGIN_LIST_OFFSET,
-      'handlePluginListOffsetRoute',
-      ctx => this.handlePluginListOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-      'handlePluginListFilterOffsetRoute',
-      ctx => this.handlePluginListFilterOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST_FILTER,
+      RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
       ctx => this.handlePluginListFilterRoute(ctx),
       true
     );
 
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST,
+    this.mapRouteState(
+      PLUGIN_LIST_ROUTE,
+      this.adminViewModel,
       'handlePluginListRoute',
-      ctx => this.handlePluginListRoute(ctx),
       true
     );
 
@@ -955,7 +920,7 @@
       this.handleDefaultRoute()
     );
 
-    page.start();
+    this.page.start(opts);
   }
 
   /**
@@ -1010,7 +975,7 @@
         if (ctx.params[0].toLowerCase() === 'self') {
           this.redirectToLogin(ctx.canonicalPath);
         } else {
-          this.redirect('/q/owner:' + encodeURIComponent(ctx.params[0]));
+          this.redirect('/q/owner:' + encodeURL(ctx.params[0]));
         }
       } else {
         const state: DashboardViewState = {
@@ -1118,36 +1083,14 @@
     this.groupViewModel.setState(state);
   }
 
-  handleGroupListOffsetRoute(ctx: PageContext) {
+  handleGroupListRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-      openCreateModal: ctx.hash === 'create',
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleGroupListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.GROUPS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleGroupListFilterRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.GROUPS,
-      filter: ctx.params['filter'] || null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
+      openCreateModal:
+        !ctx.params[1] && !ctx.params[2] && ctx.hash === 'create',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1236,112 +1179,40 @@
     this.reporting.setRepoName(repo);
   }
 
-  handleBranchListOffsetRoute(ctx: PageContext) {
+  handleBranchListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] || 0,
-      filter: null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
 
-  handleBranchListFilterOffsetRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.BRANCHES,
-      repo: ctx.params['repo'] as RepoName,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleBranchListFilterRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.BRANCHES,
-      repo: ctx.params['repo'] as RepoName,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleTagListOffsetRoute(ctx: PageContext) {
+  handleTagListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] || 0,
-      filter: null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
 
-  handleTagListFilterOffsetRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.TAGS,
-      repo: ctx.params['repo'] as RepoName,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleTagListFilterRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.TAGS,
-      repo: ctx.params['repo'] as RepoName,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleRepoListOffsetRoute(ctx: PageContext) {
+  handleRepoListRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-      openCreateModal: ctx.hash === 'create',
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleRepoListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.REPOS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleRepoListFilterRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.REPOS,
-      filter: ctx.params['filter'] || null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
+      openCreateModal:
+        !ctx.params[1] && !ctx.params[2] && ctx.hash === 'create',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1364,45 +1235,12 @@
     this.redirect(ctx.path + ',general');
   }
 
-  handlePluginListOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handlePluginListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
   handlePluginListFilterRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handlePluginListRoute(_: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1410,10 +1248,12 @@
   }
 
   handleQueryRoute(ctx: PageContext) {
-    const state: Partial<SearchViewState> = {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
       offset: ctx.params[2],
+      loading: false,
+      changes: [],
     };
     // Note that router model view must be updated before view models.
     this.setState(state as AppElementParams);
@@ -1424,10 +1264,12 @@
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    const state: Partial<SearchViewState> = {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
       offset: undefined,
+      loading: false,
+      changes: [],
     };
     // Note that router model view must be updated before view models.
     this.setState(state as AppElementParams);
@@ -1439,7 +1281,7 @@
   }
 
   handleChangeNumberLegacyRoute(ctx: PageContext) {
-    this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
+    this.redirect('/c/' + ctx.params[0]);
   }
 
   handleChangeRoute(ctx: PageContext) {
@@ -1487,10 +1329,15 @@
     const repo = ctx.params[0] as RepoName;
     const commentId = ctx.params[2] as UrlEncodedCommentId;
 
-    const comments = await this.restApiService.getDiffComments(changeNum);
-    const change = await this.restApiService.getChangeDetail(changeNum);
+    const [comments, robotComments, change] = await Promise.all([
+      this.restApiService.getDiffComments(changeNum),
+      this.restApiService.getDiffRobotComments(changeNum),
+      this.restApiService.getChangeDetail(changeNum),
+    ]);
 
-    const comment = findComment(addPath(comments), commentId);
+    const comment =
+      findComment(addPath(comments), commentId) ??
+      findComment(addPath(robotComments), commentId);
     const path = comment?.path;
     const patchsets = computeAllPatchSets(change);
     const latestPatchNum = computeLatestPatchNum(patchsets);
@@ -1734,7 +1581,7 @@
   handleDocumentationSearchRoute(ctx: PageContext) {
     const state: DocumentationViewState = {
       view: GerritView.DOCUMENTATION_SEARCH,
-      filter: ctx.params['filter'] || null,
+      filter: ctx.params[0] ?? '',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1742,9 +1589,7 @@
   }
 
   handleDocumentationSearchRedirectRoute(ctx: PageContext) {
-    this.redirect(
-      '/Documentation/q/filter:' + encodeURIComponent(ctx.params[0])
-    );
+    this.redirect('/Documentation/q/filter:' + encodeURL(ctx.params[0]));
   }
 
   handleDocumentationRedirectRoute(ctx: PageContext) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index d8761bf..87d100c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -5,45 +5,62 @@
  */
 import '../../../test/common-test-setup';
 import './gr-router';
-import {page, PageContext} from '../../../utils/page-wrapper-utils';
+import {Page, PageContext} from '../../../utils/page-wrapper-utils';
 import {
   stubBaseUrl,
   stubRestApi,
   addListenerForTest,
-  waitEventLoop,
+  waitUntilCalled,
 } from '../../../test/test-utils';
-import {GrRouter, routerToken, _testOnly_RoutePattern} from './gr-router';
+import {GrRouter, routerToken} from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
 import {
   BasePatchSetNum,
-  GroupId,
   NumericChangeId,
   PARENT,
   RepoName,
   RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {AppElementParams} from '../../gr-app-types';
+import {AppElementJustRegisteredParams} from '../../gr-app-types';
 import {assert} from '@open-wc/testing';
-import {AdminChildView} from '../../../models/views/admin';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 import {RepoDetailView} from '../../../models/views/repo';
 import {GroupDetailView} from '../../../models/views/group';
-import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
 import {PatchRangeParams} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {
+  createAdminPluginsViewState,
+  createAdminReposViewState,
+  createChangeViewState,
   createComment,
+  createDashboardViewState,
   createDiff,
+  createDiffViewState,
+  createEditViewState,
+  createGroupViewState,
   createParsedChange,
+  createRepoBranchesViewState,
+  createRepoTagsViewState,
+  createRepoViewState,
   createRevision,
+  createSearchViewState,
 } from '../../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../../types/types';
+import {ViewState} from '../../../models/views/base';
 
 suite('gr-router tests', () => {
   let router: GrRouter;
+  let page: Page;
 
   setup(() => {
     router = testResolver(routerToken);
+    page = router.page;
+  });
+
+  teardown(async () => {
+    router.finalize();
   });
 
   test('getHashFromCanonicalPath', () => {
@@ -101,7 +118,7 @@
     });
   });
 
-  test('startRouter requires auth for the right handlers', () => {
+  test('startRouterForTesting requires auth for the right handlers', () => {
     // This test encodes the lists of route handler methods that gr-router
     // automatically checks for authentication before triggering.
 
@@ -118,7 +135,7 @@
           doesNotRequireAuth[methodName] = true;
         }
       });
-    router.startRouter();
+    router._testOnly_startRouter();
 
     const actualRequiresAuth = Object.keys(requiresAuth);
     actualRequiresAuth.sort();
@@ -133,16 +150,12 @@
       'handleDiffEditRoute',
       'handleGroupAuditLogRoute',
       'handleGroupInfoRoute',
-      'handleGroupListFilterOffsetRoute',
-      'handleGroupListFilterRoute',
-      'handleGroupListOffsetRoute',
+      'handleGroupListRoute',
       'handleGroupMembersRoute',
       'handleGroupRoute',
       'handleGroupSelfRedirectRoute',
       'handleNewAgreementsRoute',
-      'handlePluginListFilterOffsetRoute',
       'handlePluginListFilterRoute',
-      'handlePluginListOffsetRoute',
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
       'handleRepoEditFileRoute',
@@ -152,9 +165,7 @@
     assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
 
     const unauthenticatedHandlers = [
-      'handleBranchListFilterOffsetRoute',
-      'handleBranchListFilterRoute',
-      'handleBranchListOffsetRoute',
+      'handleBranchListRoute',
       'handleChangeIdQueryRoute',
       'handleChangeNumberLegacyRoute',
       'handleChangeRoute',
@@ -175,16 +186,12 @@
       'handleRepoAccessRoute',
       'handleRepoDashboardsRoute',
       'handleRepoGeneralRoute',
-      'handleRepoListFilterOffsetRoute',
-      'handleRepoListFilterRoute',
-      'handleRepoListOffsetRoute',
+      'handleRepoListRoute',
       'handleRepoRoute',
       'handleQueryLegacySuffixRoute',
       'handleQueryRoute',
       'handleRegisterRoute',
-      'handleTagListFilterOffsetRoute',
-      'handleTagListFilterRoute',
-      'handleTagListOffsetRoute',
+      'handleTagListRoute',
       'handlePluginScreen',
     ];
 
@@ -276,17 +283,42 @@
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
     let handlePassThroughRoute: sinon.SinonStub;
+    let redirectToLoginStub: sinon.SinonStub;
 
-    // Simple route handlers are direct mappings from parsed route ctx to a
-    // new set of app.params. This test helper asserts that passing `ctx`
-    // into `methodName` results in setting the params specified in `params`.
-    function assertctxToParams(
-      ctx: PageContext,
-      methodName: string,
-      params: AppElementParams
+    async function checkUrlToState<T extends ViewState>(
+      url: string,
+      state: T | AppElementJustRegisteredParams
     ) {
-      (router as any)[methodName](ctx);
-      assert.deepEqual(setStateStub.lastCall.args[0], params);
+      setStateStub.reset();
+      router.page.show(url);
+      await waitUntilCalled(setStateStub, 'setState');
+      assert.isTrue(setStateStub.calledOnce);
+      assert.deepEqual(setStateStub.lastCall.firstArg, state);
+    }
+
+    async function checkRedirect(fromUrl: string, toUrl: string) {
+      redirectStub.reset();
+      router.page.show(fromUrl);
+      await waitUntilCalled(redirectStub, 'redirect');
+      assert.isTrue(redirectStub.calledOnce);
+      assert.isFalse(setStateStub.called);
+      assert.equal(redirectStub.lastCall.firstArg, toUrl);
+    }
+
+    async function checkRedirectToLogin(fromUrl: string, toUrl: string) {
+      redirectToLoginStub.reset();
+      router.page.show(fromUrl);
+      await waitUntilCalled(redirectToLoginStub, 'redirectToLogin');
+      assert.isTrue(redirectToLoginStub.calledOnce);
+      assert.isFalse(redirectStub.called);
+      assert.isFalse(setStateStub.called);
+      assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
+    }
+
+    async function checkUrlNotMatched(url: string) {
+      handlePassThroughRoute.reset();
+      router.page.show(url);
+      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
     }
 
     function createPageContext(): PageContext {
@@ -301,56 +333,49 @@
     }
 
     setup(() => {
+      stubRestApi('setInProjectLookup');
       redirectStub = sinon.stub(router, 'redirect');
+      redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
       handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      router._testOnly_startRouter();
     });
 
-    test('handleLegacyProjectDashboardRoute', () => {
-      const params = {
-        ...createPageContext(),
-        params: {0: 'gerrit/project', 1: 'dashboard:main'},
-      };
-      router.handleLegacyProjectDashboardRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(
-        redirectStub.lastCall.args[0],
+    test('LEGACY_PROJECT_DASHBOARD', async () => {
+      // LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+      await checkRedirect(
+        '/projects/gerrit/project,dashboards/dashboard:main',
         '/p/gerrit/project/+/dashboard/dashboard:main'
       );
     });
 
-    test('handleAgreementsRoute', () => {
-      router.handleAgreementsRoute();
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    test('AGREEMENTS', async () => {
+      // AGREEMENTS: /^\/settings\/agreements\/?/,
+      await checkRedirect('/settings/agreements', '/settings/#Agreements');
     });
 
-    test('handleNewAgreementsRoute', () => {
-      router.handleNewAgreementsRoute();
-      assert.isTrue(setStateStub.calledOnce);
-      assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
-    });
-
-    test('handleSettingsLegacyRoute', () => {
-      const ctx = {...createPageContext(), params: {0: 'my-token'}};
-      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
-        view: GerritView.SETTINGS,
-        emailToken: 'my-token',
+    test('NEW_AGREEMENTS', async () => {
+      // NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+      await checkUrlToState('/settings/new-agreement', {
+        view: GerritView.AGREEMENTS,
+      });
+      await checkUrlToState('/settings/new-agreement/', {
+        view: GerritView.AGREEMENTS,
       });
     });
 
-    test('handleSettingsLegacyRoute with +', () => {
-      const ctx = {...createPageContext(), params: {0: 'my-token test'}};
-      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+    test('SETTINGS', async () => {
+      // SETTINGS: /^\/settings\/?/,
+      // SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+      await checkUrlToState('/settings', {view: GerritView.SETTINGS});
+      await checkUrlToState('/settings/', {view: GerritView.SETTINGS});
+      await checkUrlToState('/settings/VE/asdf', {
         view: GerritView.SETTINGS,
-        emailToken: 'my-token+test',
+        emailToken: 'asdf',
       });
-    });
-
-    test('handleSettingsRoute', () => {
-      const ctx = createPageContext();
-      assertctxToParams(ctx, 'handleSettingsRoute', {
+      await checkUrlToState('/settings/VE/asdf%40qwer', {
         view: GerritView.SETTINGS,
+        emailToken: 'asdf@qwer',
       });
     });
 
@@ -373,7 +398,7 @@
       sinon.stub(page, 'exit').callsFake(onRegisteringExit);
       sinon.stub(page, 'start');
       sinon.stub(page, 'base');
-      router.startRouter();
+      router._testOnly_startRouter();
 
       router.handleDefaultRoute();
 
@@ -383,88 +408,71 @@
       assert.isTrue(handlePassThroughRoute.calledOnce);
     });
 
-    test('handleImproperlyEncodedPlusRoute', () => {
-      const params = {
-        ...createPageContext(),
-        canonicalPath: '/c/test/%20/42',
-        params: {0: 'test', 1: '42'},
-      };
-      // Regression test for Issue 7100.
-      router.handleImproperlyEncodedPlusRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
-
-      sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
-      router.handleImproperlyEncodedPlusRoute(params);
-      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
+    test('IMPROPERLY_ENCODED_PLUS', async () => {
+      // IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
+      await checkRedirect('/c/repo/ /42', '/c/repo/+/42');
+      await checkRedirect('/c/repo/%20/42', '/c/repo/+/42');
+      await checkRedirect('/c/repo/ /42#foo', '/c/repo/+/42#foo');
     });
 
-    test('handleQueryRoute', () => {
-      const ctx: PageContext = {
-        ...createPageContext(),
-        params: {0: 'project:foo/bar/baz'},
-      };
-      assertctxToParams(ctx, 'handleQueryRoute', {
-        view: GerritView.SEARCH,
+    test('QUERY', async () => {
+      // QUERY: /^\/q\/(.+?)(,(\d+))?$/,
+      await checkUrlToState('/q/asdf', {
+        ...createSearchViewState(),
+        query: 'asdf',
+      });
+      await checkUrlToState('/q/project:foo/bar/baz', {
+        ...createSearchViewState(),
         query: 'project:foo/bar/baz',
-        offset: undefined,
-      } as AppElementParams);
-
-      ctx.params[1] = '123';
-      ctx.params[2] = '123';
-      assertctxToParams(ctx, 'handleQueryRoute', {
-        view: GerritView.SEARCH,
-        query: 'project:foo/bar/baz',
+      });
+      await checkUrlToState('/q/asdf,123', {
+        ...createSearchViewState(),
+        query: 'asdf',
         offset: '123',
-      } as AppElementParams);
+      });
+      await checkUrlToState('/q/asdf,qwer', {
+        ...createSearchViewState(),
+        query: 'asdf,qwer',
+      });
+      await checkUrlToState('/q/asdf,qwer,123', {
+        ...createSearchViewState(),
+        query: 'asdf,qwer',
+        offset: '123',
+      });
     });
 
-    test('handleQueryLegacySuffixRoute', () => {
-      const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
-      router.handleQueryLegacySuffixRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    test('QUERY_LEGACY_SUFFIX', async () => {
+      // QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+      await checkRedirect('/q/foo+bar,n,z', '/q/foo+bar');
     });
 
-    test('handleChangeIdQueryRoute', () => {
-      const ctx = {
-        ...createPageContext(),
-        params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
-      };
-      assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
-        view: GerritView.SEARCH,
+    test('CHANGE_ID_QUERY', async () => {
+      // CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+      await checkUrlToState('/id/I0123456789abcdef0123456789abcdef01234567', {
+        ...createSearchViewState(),
         query: 'I0123456789abcdef0123456789abcdef01234567',
-        offset: undefined,
-      } as AppElementParams);
-    });
-
-    suite('handleRegisterRoute', () => {
-      test('happy path', () => {
-        const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
-      });
-
-      test('no param', () => {
-        const ctx = createPageContext();
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
-      });
-
-      test('prevent redirect', () => {
-        const ctx = {...createPageContext(), params: {0: '/register'}};
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
     });
 
-    suite('handleRootRoute', () => {
+    test('REGISTER', async () => {
+      // REGISTER: /^\/register(\/.*)?$/,
+      await checkUrlToState('/register/foo/bar', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+
+      await checkUrlToState('/register', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/'));
+
+      await checkUrlToState('/register/register', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/'));
+    });
+
+    suite('ROOT', () => {
       test('closes for closeAfterLogin', () => {
         const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
         const closeStub = sinon.stub(window, 'close');
@@ -474,737 +482,413 @@
         assert.isFalse(redirectStub.called);
       });
 
-      test('redirects to dashboard if logged in', () => {
-        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = router.handleRootRoute(ctx);
-        assert.isOk(result);
-        return result!.then(() => {
-          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-        });
+      test('ROOT logged in', async () => {
+        stubRestApi('getLoggedIn').resolves(true);
+        await checkRedirect('/', '/dashboard/self');
       });
 
-      test('redirects to open changes if not logged in', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = router.handleRootRoute(ctx);
-        assert.isOk(result);
-        return result!.then(() => {
-          assert.isTrue(
-            redirectStub.calledWithExactly('/q/status:open+-is:wip')
-          );
-        });
+      test('ROOT not logged in', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirect('/', '/q/status:open+-is:wip');
       });
 
-      suite('GWT hash-path URLs', () => {
-        test('redirects hash-path URLs', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar/baz',
-            hash: '/foo/bar/baz',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+      suite('ROOT GWT hash-path URLs', () => {
+        test('ROOT hash-path URLs', async () => {
+          await checkRedirect('/#/foo/bar/baz', '/foo/bar/baz');
         });
 
-        test('redirects hash-path URLs w/o leading slash', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#foo/bar/baz',
-            hash: 'foo/bar/baz',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        test('ROOT hash-path URLs w/o leading slash', async () => {
+          await checkRedirect('/#foo/bar/baz', '/foo/bar/baz');
         });
 
-        test('normalizes "/ /" in hash to "/+/"', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar/+/123/4',
-            hash: '/foo/bar/ /123/4',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        test('ROOT normalizes "/ /" in hash to "/+/"', async () => {
+          await checkRedirect('/#/foo/bar/+/123/4', '/foo/bar/+/123/4');
         });
 
-        test('prepends baseurl to hash-path', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar',
-            hash: '/foo/bar',
-          };
+        test('ROOT prepends baseurl to hash-path', async () => {
           stubBaseUrl('/baz');
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+          await checkRedirect('/#/foo/bar', '/baz/foo/bar');
         });
 
-        test('normalizes /VE/ settings hash-paths', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/VE/foo/bar',
-            hash: '/VE/foo/bar',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
+        test('ROOT normalizes /VE/ settings hash-paths', async () => {
+          await checkRedirect('/#/VE/foo/bar', '/settings/VE/foo/bar');
         });
 
-        test('does not drop "inner hashes"', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar#baz',
-            hash: '/foo/bar',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        test('ROOT does not drop "inner hashes"', async () => {
+          await checkRedirect('/#/foo/bar#baz', '/foo/bar#baz');
         });
       });
     });
 
-    suite('handleDashboardRoute', () => {
-      let redirectToLoginStub: sinon.SinonStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+    suite('DASHBOARD', () => {
+      test('DASHBOARD own dashboard but signed out redirects to login', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirectToLogin('/dashboard/seLF', '/dashboard/seLF');
       });
 
-      test('own dashboard but signed out redirects to login', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'seLF'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isTrue(redirectToLoginStub.calledOnce);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(setStateStub.called);
-        });
+      test('DASHBOARD non-self dashboard but signed out redirects', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirect('/dashboard/foo', '/q/owner:foo');
       });
 
-      test('non-self dashboard but signed out does not redirect', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'foo'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setStateStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-        });
-      });
-
-      test('dashboard while signed in sets params', () => {
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'foo'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'foo',
-          });
+      test('DASHBOARD', async () => {
+        // DASHBOARD: /^\/dashboard\/(.+)$/,
+        await checkUrlToState('/dashboard/foo', {
+          ...createDashboardViewState(),
+          user: 'foo',
         });
       });
     });
 
-    suite('handleCustomDashboardRoute', () => {
-      let redirectToLoginStub: sinon.SinonStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+    suite('CUSTOM_DASHBOARD', () => {
+      test('CUSTOM_DASHBOARD no user specified', async () => {
+        await checkRedirect('/dashboard/', '/dashboard/self');
       });
 
-      test('no user specified', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(setStateStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+      test('CUSTOM_DASHBOARD', async () => {
+        // CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+        await checkUrlToState('/dashboard?title=Custom Dashboard&a=b&d=e', {
+          ...createDashboardViewState(),
+          sections: [
+            {name: 'a', query: 'b'},
+            {name: 'd', query: 'e'},
+          ],
+          title: 'Custom Dashboard',
         });
-      });
-
-      test('custom dashboard without title', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=e',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [
-              {name: 'a', query: 'b'},
-              {name: 'd', query: 'e'},
-            ],
-            title: 'Custom Dashboard',
-          });
-        });
-      });
-
-      test('custom dashboard with title', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=&=e&title=t',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [{name: 'a', query: 'b'}],
-            title: 't',
-          });
-        });
-      });
-
-      test('custom dashboard with foreach', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=&=e&foreach=is:open',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [{name: 'a', query: 'is:open b'}],
-            title: 'Custom Dashboard',
-          });
+        await checkUrlToState('/dashboard?a=b&c&d=&=e&foreach=is:open', {
+          ...createDashboardViewState(),
+          sections: [{name: 'a', query: 'is:open b'}],
+          title: 'Custom Dashboard',
         });
       });
     });
 
     suite('group routes', () => {
-      test('handleGroupInfoRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        router.handleGroupInfoRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      test('GROUP_INFO', async () => {
+        // GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+        await checkRedirect('/admin/groups/1234,info', '/admin/groups/1234');
       });
 
-      test('handleGroupAuditLogRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        assertctxToParams(ctx, 'handleGroupAuditLogRoute', {
-          view: GerritView.GROUP,
+      test('GROUP_AUDIT_LOG', async () => {
+        // GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+        await checkUrlToState('/admin/groups/1234,audit-log', {
+          ...createGroupViewState(),
           detail: GroupDetailView.LOG,
-          groupId: '1234' as GroupId,
+          groupId: '1234',
         });
       });
 
-      test('handleGroupMembersRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        assertctxToParams(ctx, 'handleGroupMembersRoute', {
-          view: GerritView.GROUP,
+      test('GROUP_MEMBERS', async () => {
+        // GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+        await checkUrlToState('/admin/groups/1234,members', {
+          ...createGroupViewState(),
           detail: GroupDetailView.MEMBERS,
-          groupId: '1234' as GroupId,
+          groupId: '1234',
         });
       });
 
-      test('handleGroupListOffsetRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          offset: 0,
-          filter: null,
-          openCreateModal: false,
-        });
+      test('GROUP_LIST', async () => {
+        // GROUP_LIST: /^\/admin\/groups(\/q\/filter:(.*?))?(,(\d+))?(\/)?$/,
 
-        ctx.params[1] = '42';
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+        const defaultState: AdminViewState = {
           view: GerritView.ADMIN,
           adminView: AdminChildView.GROUPS,
-          offset: '42',
-          filter: null,
+          offset: '0',
           openCreateModal: false,
-        });
-
-        ctx.hash = 'create';
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          offset: '42',
           filter: null,
+        };
+
+        await checkUrlToState('/admin/groups', defaultState);
+        await checkUrlToState('/admin/groups/', defaultState);
+        await checkUrlToState('/admin/groups#create', {
+          ...defaultState,
           openCreateModal: true,
         });
-      });
-
-      test('handleGroupListFilterOffsetRoute', () => {
-        const ctx = {
-          ...createPageContext(),
-          params: {filter: 'foo', offset: '42'},
-        };
-        assertctxToParams(ctx, 'handleGroupListFilterOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
+        await checkUrlToState('/admin/groups,42', {
+          ...defaultState,
           offset: '42',
+        });
+        // #create is ignored when there is an offset
+        await checkUrlToState('/admin/groups,42#create', {
+          ...defaultState,
+          offset: '42',
+        });
+
+        await checkUrlToState('/admin/groups/q/filter:foo', {
+          ...defaultState,
           filter: 'foo',
         });
+        await checkUrlToState('/admin/groups/q/filter:foo/%2F%20%2525%252F', {
+          ...defaultState,
+          filter: 'foo// %/',
+        });
+        await checkUrlToState('/admin/groups/q/filter:foo,42', {
+          ...defaultState,
+          filter: 'foo',
+          offset: '42',
+        });
+        // #create is ignored when filtering
+        await checkUrlToState('/admin/groups/q/filter:foo,42#create', {
+          ...defaultState,
+          filter: 'foo',
+          offset: '42',
+        });
       });
 
-      test('handleGroupListFilterRoute', () => {
-        const ctx = {...createPageContext(), params: {filter: 'foo'}};
-        assertctxToParams(ctx, 'handleGroupListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          filter: 'foo',
-        });
-      });
-
-      test('handleGroupRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleGroupRoute', {
-          view: GerritView.GROUP,
-          groupId: '4321' as GroupId,
+      test('GROUP', async () => {
+        // GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+        await checkUrlToState('/admin/groups/4321', {
+          ...createGroupViewState(),
+          groupId: '4321',
         });
       });
     });
 
-    suite('repo routes', () => {
-      test('handleProjectsOldRoute', () => {
-        const ctx = {...createPageContext(), params: {}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-      });
-
-      test('handleProjectsOldRoute test', () => {
-        const ctx = {...createPageContext(), params: {1: 'test'}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-      });
-
-      test('handleProjectsOldRoute test,branches', () => {
-        const ctx = {...createPageContext(), params: {1: 'test,branches'}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-          redirectStub.lastCall.args[0],
+    suite('REPO*', () => {
+      test('PROJECT_OLD', async () => {
+        // PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+        await checkRedirect('/admin/projects/', '/admin/repos/');
+        await checkRedirect('/admin/projects/test', '/admin/repos/test');
+        await checkRedirect(
+          '/admin/projects/test,branches',
           '/admin/repos/test,branches'
         );
       });
 
-      test('handleRepoRoute', () => {
-        const ctx = {...createPageContext(), path: '/admin/repos/test'};
-        router.handleRepoRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-          redirectStub.lastCall.args[0],
-          '/admin/repos/test,general'
-        );
+      test('REPO', async () => {
+        // REPO: /^\/admin\/repos\/([^,]+)$/,
+        await checkRedirect('/admin/repos/test', '/admin/repos/test,general');
       });
 
-      test('handleRepoGeneralRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoGeneralRoute', {
-          view: GerritView.REPO,
+      test('REPO_GENERAL', async () => {
+        // REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
+        await checkUrlToState('/admin/repos/4321,general', {
+          ...createRepoViewState(),
           detail: RepoDetailView.GENERAL,
           repo: '4321' as RepoName,
         });
       });
 
-      test('handleRepoCommandsRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoCommandsRoute', {
-          view: GerritView.REPO,
+      test('REPO_COMMANDS', async () => {
+        // REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+        await checkUrlToState('/admin/repos/4321,commands', {
+          ...createRepoViewState(),
           detail: RepoDetailView.COMMANDS,
           repo: '4321' as RepoName,
         });
       });
 
-      test('handleRepoAccessRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoAccessRoute', {
-          view: GerritView.REPO,
+      test('REPO_ACCESS', async () => {
+        // REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+        await checkUrlToState('/admin/repos/4321,access', {
+          ...createRepoViewState(),
           detail: RepoDetailView.ACCESS,
           repo: '4321' as RepoName,
         });
       });
 
-      suite('branch list routes', () => {
-        test('handleBranchListOffsetRoute', () => {
-          const ctx: PageContext = {
-            ...createPageContext(),
-            params: {0: '4321'},
-          };
-          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: 0,
-            filter: null,
-          });
-
-          ctx.params[2] = '42';
-          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: null,
-          });
+      test('BRANCH_LIST', async () => {
+        await checkUrlToState('/admin/repos/4321,branches', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
         });
-
-        test('handleBranchListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleBranchListFilterOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,branches,42', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
         });
-
-        test('handleBranchListFilterRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo'},
-          };
-          assertctxToParams(ctx, 'handleBranchListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,branches/q/filter:foo,42', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
+          filter: 'foo',
         });
+        await checkUrlToState('/admin/repos/4321,branches/q/filter:foo', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          filter: 'foo',
+        });
+        await checkUrlToState(
+          '/admin/repos/asdf/%2F%20%2525%252Fqwer,branches/q/filter:foo/%2F%20%2525%252F',
+          {
+            ...createRepoBranchesViewState(),
+            repo: 'asdf// %/qwer' as RepoName,
+            filter: 'foo// %/',
+          }
+        );
       });
 
-      suite('tag list routes', () => {
-        test('handleTagListOffsetRoute', () => {
-          const ctx = {...createPageContext(), params: {0: '4321'}};
-          assertctxToParams(ctx, 'handleTagListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            offset: 0,
-            filter: null,
-          });
+      test('TAG_LIST', async () => {
+        await checkUrlToState('/admin/repos/4321,tags', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
         });
-
-        test('handleTagListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleTagListFilterOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,tags,42', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
         });
-
-        test('handleTagListFilterRoute', () => {
-          const ctx: PageContext = {
-            ...createPageContext(),
-            params: {repo: '4321'},
-          };
-          assertctxToParams(ctx, 'handleTagListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            filter: null,
-          });
-
-          ctx.params.filter = 'foo';
-          assertctxToParams(ctx, 'handleTagListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,tags/q/filter:foo,42', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
+          filter: 'foo',
         });
+        await checkUrlToState('/admin/repos/4321,tags/q/filter:foo', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          filter: 'foo',
+        });
+        await checkUrlToState(
+          '/admin/repos/asdf/%2F%20%2525%252Fqwer,tags/q/filter:foo/%2F%20%2525%252F',
+          {
+            ...createRepoTagsViewState(),
+            repo: 'asdf// %/qwer' as RepoName,
+            filter: 'foo// %/',
+          }
+        );
       });
 
-      suite('repo list routes', () => {
-        test('handleRepoListOffsetRoute', () => {
-          const ctx = createPageContext();
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          ctx.params[1] = '42';
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: null,
-            openCreateModal: false,
-          });
-
-          ctx.hash = 'create';
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: null,
-            openCreateModal: true,
-          });
+      test('REPO_LIST', async () => {
+        await checkUrlToState('/admin/repos', {
+          ...createAdminReposViewState(),
         });
-
-        test('handleRepoListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleRepoListFilterOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/', {
+          ...createAdminReposViewState(),
         });
-
-        test('handleRepoListFilterRoute', () => {
-          const ctx = createPageContext();
-          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            filter: null,
-          });
-
-          ctx.params.filter = 'foo';
-          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos,42', {
+          ...createAdminReposViewState(),
+          offset: '42',
+        });
+        await checkUrlToState('/admin/repos#create', {
+          ...createAdminReposViewState(),
+          openCreateModal: true,
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo', {
+          ...createAdminReposViewState(),
+          filter: 'foo',
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo/%2F%20%2525%252F', {
+          ...createAdminReposViewState(),
+          filter: 'foo// %/',
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo,42', {
+          ...createAdminReposViewState(),
+          filter: 'foo',
+          offset: '42',
         });
       });
     });
 
-    suite('plugin routes', () => {
-      test('handlePluginListOffsetRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: 0,
-          filter: null,
-        });
-
-        ctx.params[1] = '42';
-        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: '42',
-          filter: null,
-        });
+    test('PLUGIN_LIST', async () => {
+      await checkUrlToState('/admin/plugins', {
+        ...createAdminPluginsViewState(),
       });
-
-      test('handlePluginListFilterOffsetRoute', () => {
-        const ctx = {
-          ...createPageContext(),
-          params: {filter: 'foo', offset: '42'},
-        };
-        assertctxToParams(ctx, 'handlePluginListFilterOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: '42',
-          filter: 'foo',
-        });
+      await checkUrlToState('/admin/plugins/', {
+        ...createAdminPluginsViewState(),
       });
-
-      test('handlePluginListFilterRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          filter: null,
-        });
-
-        ctx.params.filter = 'foo';
-        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          filter: 'foo',
-        });
+      await checkUrlToState('/admin/plugins,42', {
+        ...createAdminPluginsViewState(),
+        offset: '42',
       });
-
-      test('handlePluginListRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-        });
+      await checkUrlToState('/admin/plugins/q/filter:foo', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo%2F%20%2525%252F', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo/ %/',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo,42', {
+        ...createAdminPluginsViewState(),
+        offset: '42',
+        filter: 'foo',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo,asdf', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo,asdf',
       });
     });
 
-    suite('change/diff routes', () => {
-      test('handleChangeNumberLegacyRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '12345'}};
-        router.handleChangeNumberLegacyRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+    suite('CHANGE* / DIFF*', () => {
+      test('CHANGE_NUMBER_LEGACY', async () => {
+        // CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+        await checkRedirect('/12345', '/c/12345');
       });
 
-      test('handleChangeLegacyRoute', async () => {
-        stubRestApi('getFromProjectLookup').returns(
-          Promise.resolve('project' as RepoName)
-        );
-        const ctx = {
-          ...createPageContext(),
-          params: {0: '1234', 1: 'comment/6789'},
-        };
-        router.handleChangeLegacyRoute(ctx);
-        await waitEventLoop();
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
+      test('CHANGE_LEGACY', async () => {
+        // CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
+        stubRestApi('getFromProjectLookup').resolves('project' as RepoName);
+        await checkRedirect('/c/1234', '/c/project/+/1234/');
+        await checkRedirect(
+          '/c/1234/comment/6789',
+          '/c/project/+/1234/comment/6789'
         );
       });
 
-      test('handleLegacyLinenum w/ @321', () => {
-        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
-        router.handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
+      test('DIFF_LEGACY_LINENUM', async () => {
+        await checkRedirect(
+          '/c/1234/3..8/foo/bar@321',
+          '/c/1234/3..8/foo/bar#321'
+        );
+        await checkRedirect(
+          '/c/1234/3..8/foo/bar@b321',
+          '/c/1234/3..8/foo/bar#b321'
         );
       });
 
-      test('handleLegacyLinenum w/ @b123', () => {
-        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
-        router.handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
-        );
-      });
-
-      suite('handleChangeRoute', () => {
-        function makeParams(_path: string, _hash: string): PageContext {
-          return {
-            ...createPageContext(),
-            params: {
-              0: 'foo/bar', // 0 Project
-              1: '1234', // 1 Change number
-              2: '', // 2 Unused
-              3: '', // 3 Unused
-              4: '4', // 4 Base patch number
-              5: '', // 5 Unused
-              6: '7', // 6 Patch number
-            },
-          };
-        }
-
-        setup(() => {
-          stubRestApi('setInProjectLookup');
+      test('CHANGE', async () => {
+        // CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+        await checkUrlToState('/c/test-project/+/42', {
+          ...createChangeViewState(),
+          basePatchNum: undefined,
+          patchNum: undefined,
         });
-
-        test('change view', () => {
-          const ctx = makeParams('', '');
-          assertctxToParams(ctx, 'handleChangeRoute', {
-            view: GerritView.CHANGE,
-            childView: ChangeChildView.OVERVIEW,
-            repo: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
-            basePatchNum: 4 as BasePatchSetNum,
-            patchNum: 7 as RevisionPatchSetNum,
-          });
-          assert.isFalse(redirectStub.called);
+        await checkUrlToState('/c/test-project/+/42/7', {
+          ...createChangeViewState(),
+          basePatchNum: PARENT,
+          patchNum: 7,
         });
-
-        test('params', () => {
-          const ctx = makeParams('', '');
-          const queryMap = new URLSearchParams();
-          queryMap.set('tab', 'checks');
-          queryMap.set('filter', 'fff');
-          queryMap.set('select', 'sss');
-          queryMap.set('attempt', '1');
-          queryMap.set('checksRunsSelected', 'asdf,qwer');
-          queryMap.set('checksResultsFilter', 'asdf.*qwer');
-          ctx.querystring = queryMap.toString();
-          assertctxToParams(ctx, 'handleChangeRoute', {
-            view: GerritView.CHANGE,
-            childView: ChangeChildView.OVERVIEW,
-            repo: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
-            basePatchNum: 4 as BasePatchSetNum,
-            patchNum: 7 as RevisionPatchSetNum,
+        await checkUrlToState('/c/test-project/+/42/4..7', {
+          ...createChangeViewState(),
+          basePatchNum: 4,
+          patchNum: 7,
+        });
+        await checkUrlToState(
+          '/c/test-project/+/42/4..7?tab=checks&filter=fff&attempt=1&checksRunsSelected=asdf,qwer&checksResultsFilter=asdf.*qwer',
+          {
+            ...createChangeViewState(),
+            basePatchNum: 4,
+            patchNum: 7,
             attempt: 1,
             filter: 'fff',
             tab: 'checks',
             checksRunsSelected: new Set(['asdf', 'qwer']),
             checksResultsFilter: 'asdf.*qwer',
-          });
-        });
+          }
+        );
+      });
+
+      test('COMMENTS_TAB', async () => {
+        // COMMENTS_TAB: /^\/c\/(.+)\/\+\/(\d+)\/comments(?:\/)?(\w+)?\/?$/,
+        await checkUrlToState(
+          '/c/gerrit/+/264833/comments/00049681_f34fd6a9/',
+          {
+            ...createChangeViewState(),
+            repo: 'gerrit' as RepoName,
+            changeNum: 264833 as NumericChangeId,
+            commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
+          }
+        );
       });
 
       suite('handleDiffRoute', () => {
-        function makeParams(path: string, hash: string): PageContext {
-          return {
-            ...createPageContext(),
-            hash,
-            params: {
-              0: 'foo/bar', // 0 Project
-              1: '1234', // 1 Change number
-              2: '', // 2 Unused
-              3: '', // 3 Unused
-              4: '4', // 4 Base patch number
-              5: '', // 5 Unused
-              6: '7', // 6 Patch number
-              7: '', // 7 Unused,
-              8: path, // 8 Diff path
-            },
-          };
-        }
-
-        setup(() => {
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('diff view', () => {
-          const ctx = makeParams('foo/bar/baz', 'b44');
-          assertctxToParams(ctx, 'handleDiffRoute', {
-            view: GerritView.CHANGE,
-            childView: ChangeChildView.DIFF,
-            repo: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
+        test('DIFF', async () => {
+          // DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
+          await checkUrlToState('/c/test-project/+/42/4..7/foo/bar/baz#b44', {
+            ...createDiffViewState(),
             basePatchNum: 4 as BasePatchSetNum,
             patchNum: 7 as RevisionPatchSetNum,
             diffView: {
@@ -1213,10 +897,9 @@
               leftSide: true,
             },
           });
-          assert.isFalse(redirectStub.called);
         });
 
-        test('comment route base..1', async () => {
+        test('COMMENT base..1', async () => {
           const change: ParsedChangeInfo = createParsedChange();
           const repo = change.project;
           const changeNum = change._number;
@@ -1228,19 +911,13 @@
             filepath: [{...createComment(), id, patch_set: ps, line}],
           });
 
-          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-          assert.deepEqual(groups!.slice(1), [repo, `${changeNum}`, id]);
-
-          await router.handleCommentRoute({params: groups!.slice(1)} as any);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(
-            redirectStub.lastCall.args[0],
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
             `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
 
-        test('comment route 1..2', async () => {
+        test('COMMENT 1..2', async () => {
           const change: ParsedChangeInfo = {
             ...createParsedChange(),
             revisions: {
@@ -1260,16 +937,11 @@
           });
           const diffStub = stubRestApi('getDiff');
 
-          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-
           // If getDiff() returns a diff with changes, then we will compare
           // the patchset of the comment (1) against latest (2).
           diffStub.onFirstCall().resolves(createDiff());
-          await router.handleCommentRoute({params: groups!.slice(1)} as any);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(
-            redirectStub.lastCall.args[0],
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
             `/c/${repo}/+/${changeNum}/${ps}..2/filepath#b${line}`
           );
 
@@ -1279,122 +951,97 @@
             ...createDiff(),
             content: [],
           });
-          await router.handleCommentRoute({params: groups!.slice(1)} as any);
-          assert.isTrue(redirectStub.calledTwice);
-          assert.equal(
-            redirectStub.lastCall.args[0],
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
             `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
-
-        test('comments route', () => {
-          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
-          assert.deepEqual(groups!.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertctxToParams(
-            {params: groups!.slice(1)} as any,
-            'handleCommentsRoute',
-            {
-              repo: 'gerrit' as RepoName,
-              changeNum: 264833 as NumericChangeId,
-              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
-              view: GerritView.CHANGE,
-              childView: ChangeChildView.OVERVIEW,
-            }
-          );
-        });
       });
 
-      test('handleDiffEditRoute', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          hash: '',
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '3', // 2 Patch num
-            3: 'foo/bar/baz', // 3 File path
-          },
-        };
-        const appParams: ChangeViewState = {
+      test('DIFF_EDIT', async () => {
+        // DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
+        await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit', {
+          ...createEditViewState(),
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
           childView: ChangeChildView.EDIT,
           patchNum: 3 as RevisionPatchSetNum,
           editView: {path: 'foo/bar/baz', lineNum: 0},
-        };
-
-        router.handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
-      });
-
-      test('handleDiffEditRoute with lineNum', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          hash: '4',
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '3', // 2 Patch num
-            3: 'foo/bar/baz', // 3 File path
-          },
-        };
-        const appParams: ChangeViewState = {
+        });
+        await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit#4', {
+          ...createEditViewState(),
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
           childView: ChangeChildView.EDIT,
           patchNum: 3 as RevisionPatchSetNum,
           editView: {path: 'foo/bar/baz', lineNum: 4},
-        };
-
-        router.handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+        });
       });
 
-      test('handleChangeEditRoute', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '',
-            3: '3', // 3 Patch num
-          },
-        };
-        const appParams: ChangeViewState = {
+      test('CHANGE_EDIT', async () => {
+        // CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
+        await checkUrlToState('/c/foo/bar/+/1234/3,edit', {
+          ...createChangeViewState(),
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
           childView: ChangeChildView.OVERVIEW,
           patchNum: 3 as RevisionPatchSetNum,
           edit: true,
-        };
-
-        router.handleChangeEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+        });
       });
     });
 
-    test('handlePluginScreen', () => {
-      const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
-      assertctxToParams(ctx, 'handlePluginScreen', {
+    test('LOG_IN_OR_OUT pass through', async () => {
+      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
+      await checkUrlNotMatched('/login/asdf');
+      await checkUrlNotMatched('/logout/asdf');
+    });
+
+    test('PLUGIN_SCREEN', async () => {
+      // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+      await checkUrlToState('/x/foo/bar', {
         view: GerritView.PLUGIN_SCREEN,
         plugin: 'foo',
         screen: 'bar',
       });
-      assert.isFalse(redirectStub.called);
+    });
+
+    test('DOCUMENTATION_SEARCH*', async () => {
+      // DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+      // DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+      await checkRedirect(
+        '/Documentation/q/asdf',
+        '/Documentation/q/filter:asdf'
+      );
+      await checkRedirect(
+        '/Documentation/q/as%3Fdf',
+        '/Documentation/q/filter:as%3Fdf'
+      );
+
+      await checkUrlToState('/Documentation/q/filter:', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: '',
+      });
+      await checkUrlToState('/Documentation/q/filter:asdf', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'asdf',
+      });
+      // Percent decoding works fine. page.js decodes twice, so the only problem
+      // is having `%25` in the URL, because the first decoding pass will yield
+      // `%`, and then the second decoding pass will throw `URI malformed`.
+      await checkUrlToState('/Documentation/q/filter:as%20%2fdf', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'as /df',
+      });
+      // We accept and process double-encoded values, but only *require* it for
+      // the percent symbol `%`.
+      await checkUrlToState('/Documentation/q/filter:as%252f%2525df', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'as/%df',
+      });
     });
   });
 });
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 de92b16..23dce97 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
@@ -347,9 +347,7 @@
     );
     this.renderPrefs = {
       ...this.renderPrefs,
-      use_lit_components: this.flags.isEnabled(
-        KnownExperimentId.DIFF_RENDERING_LIT
-      ),
+      use_lit_components: true,
     };
     this.addEventListener(
       // These are named inconsistently for a reason:
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index e9da5b6..c74993f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -732,7 +732,7 @@
       changedProperties.has('focusLineNum') ||
       changedProperties.has('leftSide')
     ) {
-      this.initLineOfInterestAndCursor();
+      this.initCursor();
     }
     if (
       changedProperties.has('change') ||
@@ -774,6 +774,7 @@
         .change=${this.change}
         .patchRange=${this.patchRange}
         .file=${file}
+        .lineOfInterest=${this.getLineOfInterest()}
         .path=${this.path}
         .projectName=${this.change?.project}
         @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
@@ -1350,13 +1351,6 @@
     return {path: fileList[idx]};
   }
 
-  // Private but used in tests.
-  initLineOfInterestAndCursor() {
-    if (!this.diffHost) return;
-    this.diffHost.lineOfInterest = this.getLineOfInterest();
-    this.initCursor();
-  }
-
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
     if (!this.change) return;
     if (!this.patchNum) return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 9bbe4b3..889e9dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -1315,7 +1315,7 @@
     test('hash is determined from viewState', async () => {
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
-      const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
+      const initLineStub = sinon.stub(element, 'initCursor');
 
       element.focusLineNum = 123;
 
@@ -1819,7 +1819,7 @@
 
     test('File change should trigger setUrl once', async () => {
       element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sinon.stub(element, 'initLineOfInterestAndCursor');
+      sinon.stub(element, 'initCursor');
 
       // Load file1
       viewModel.setState({
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 0092193..ea5e9f3 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
-import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function documentationGenerator(counter: number) {
   return {
@@ -31,7 +32,7 @@
   let documentationSearches: DocResult[];
 
   setup(async () => {
-    sinon.stub(page, 'show');
+    sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture(
       html`<gr-documentation-search></gr-documentation-search>`
     );
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 0261992..68e7309 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -28,6 +28,7 @@
   justRegistered: boolean;
 }
 
+// TODO: Get rid of this type. <gr-app-element> needs to be refactored for that.
 export type AppElementParams =
   | DashboardViewState
   | GroupViewState
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index e7c0536..71f8391 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -88,7 +88,7 @@
       /* HTML */ `
         <div class="container">
           <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
-          <a class="ownerLink" href="/q/owner:user-31%2540" tabindex="-1">
+          <a class="ownerLink" href="/q/owner:user-31@" tabindex="-1">
             <span class="hovercardTargetWrapper">
               <gr-avatar hidden="" imagesize="32"> </gr-avatar>
               <span
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 66beaf1..1ac89d6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -275,9 +275,7 @@
         this.save();
       });
     }
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.messagePlaceholder = 'Mention others with @';
-    }
+    this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
       () => this.getUserModel().account$,
@@ -821,8 +819,7 @@
     // fixed. Currently diff line doesn't match commit message line, because
     // of metadata in diff, which aren't in content api request.
     if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
-    // TODO(milutin): disable user suggestions for owners, after user study.
-    // if (this.isOwner) return nothing;
+    if (this.isOwner) return nothing;
     return html`<gr-button
       link
       class="action suggestEdit"
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 627ea27..45eca40 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,8 +18,6 @@
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getAppContext} from '../../../services/app-context';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -36,8 +34,6 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getConfigModel = resolve(this, configModelToken);
 
   // Private const but used in tests.
@@ -214,9 +210,7 @@
 
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.convertEmailsToAccountChips();
-    }
+    this.convertEmailsToAccountChips();
   }
 
   private convertEmailsToAccountChips() {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 3881c62..3187ada 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -15,14 +15,9 @@
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
 import {createConfig} from '../../../test/test-data-generators';
-import {
-  queryAndAssert,
-  stubFlags,
-  waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
@@ -412,38 +407,7 @@
       );
     });
 
-    test('does not handle @mentions if not enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(false);
-      element.content = '@someone@google.com';
-      await element.updateComplete;
-
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                @
-                <a
-                  href="mailto:someone@google.com"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  someone@google.com
-                </a>
-              </p>
-            </div>
-          </marked-element>
-        `
-      );
-    });
-
-    test('handles @mentions if enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
+    test('handles @mentions', async () => {
       element.content = '@someone@google.com';
       await element.updateComplete;
 
@@ -470,9 +434,6 @@
     });
 
     test('does not handle @mentions that is part of a code block', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.content = '`@`someone@google.com';
       await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
index b217562..a1ac781 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -79,7 +79,7 @@
         </div>
         <div class="links">
           <gr-icon icon="link" class="linkIcon"></gr-icon>
-          <a href="/q/owner:kermit%2540gmail.com">Changes</a>
+          <a href="/q/owner:kermit@gmail.com">Changes</a>
           ·
           <a href="/dashboard/31415926535">Dashboard</a>
         </div>
@@ -114,7 +114,7 @@
         </div>
         <div class="links">
           <gr-icon class="linkIcon" icon="link"> </gr-icon>
-          <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
+          <a href="/q/owner:kermit@gmail.com"> Changes </a>
           ·
           <a href="/dashboard/31415926535"> Dashboard </a>
         </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 449c041..7e90c8d 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -7,13 +7,14 @@
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
 import {fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -48,6 +49,8 @@
 
   private reloadTask?: DelayedTask;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   override disconnectedCallback() {
     this.reloadTask?.cancel();
     super.disconnectedCallback();
@@ -121,30 +124,18 @@
       </div>
       <slot></slot>
       <nav>
-        Page ${this.computePage(this.offset, this.itemsPerPage)}
+        Page ${this.computePage()}
         <a
           id="prevArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            -1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
+          href=${this.computeNavLink(-1)}
           ?hidden=${this.loading || this.offset === 0}
         >
           <gr-icon icon="chevron_left"></gr-icon>
         </a>
         <a
           id="nextArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
-          ?hidden=${this.hideNextArrow(this.loading, this.items)}
+          href=${this.computeNavLink(1)}
+          ?hidden=${this.hideNextArrow()}
         >
           <gr-icon icon="chevron_right"></gr-icon>
         </a>
@@ -177,12 +168,12 @@
       () => {
         if (!this.isConnected || !this.path) return;
         if (filter) {
-          // TODO: Use navigation service instead of `page.show()` directly.
-          page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          this.getNavigation().setUrl(
+            `${this.path}/q/filter:${encodeURL(filter)}`
+          );
           return;
         }
-        // TODO: Use navigation service instead of `page.show()` directly.
-        page.show(this.path);
+        this.getNavigation().setUrl(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
     );
@@ -193,19 +184,13 @@
   }
 
   // private but used in test
-  computeNavLink(
-    offset: number,
-    direction: number,
-    itemsPerPage: number,
-    filter: string | undefined,
-    path = ''
-  ) {
+  computeNavLink(direction: number) {
     // Offset could be a string when passed from the router.
-    offset = +(offset || 0);
-    const newOffset = Math.max(0, offset + itemsPerPage * direction);
-    let href = getBaseUrl() + path;
-    if (filter) {
-      href += '/q/filter:' + encodeURL(filter, false);
+    const offset = +(this.offset || 0);
+    const newOffset = Math.max(0, offset + this.itemsPerPage * direction);
+    let href = getBaseUrl() + (this.path ?? '');
+    if (this.filter) {
+      href += '/q/filter:' + encodeURL(this.filter);
     }
     if (newOffset > 0) {
       href += `,${newOffset}`;
@@ -214,11 +199,9 @@
   }
 
   // private but used in test
-  hideNextArrow(loading?: boolean, items?: unknown[]) {
-    if (loading || !items || !items.length) {
-      return true;
-    }
-    const lastPage = items.length < this.itemsPerPage + 1;
+  hideNextArrow() {
+    if (this.loading || !this.items?.length) return true;
+    const lastPage = this.items.length < this.itemsPerPage + 1;
     return lastPage;
   }
 
@@ -226,8 +209,8 @@
   // to either support a decimal or make it go to the nearest
   // whole number (e.g 3).
   // private but used in test
-  computePage(offset: number, itemsPerPage: number) {
-    return offset / itemsPerPage + 1;
+  computePage() {
+    return this.offset / this.itemsPerPage + 1;
   }
 
   private handleFilterBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index bbbef72..cce2840 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-list-view';
 import {GrListView} from './gr-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubBaseUrl} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-list-view tests', () => {
   let element: GrListView;
@@ -57,37 +58,26 @@
   });
 
   test('computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
+    element.offset = 25;
+    element.itemsPerPage = 25;
+    element.filter = 'test';
+    element.path = '/admin/projects';
 
     stubBaseUrl('');
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test,50'
-    );
+    assert.equal(element.computeNavLink(1), '/admin/projects/q/filter:test,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test'
-    );
+    assert.equal(element.computeNavLink(-1), '/admin/projects/q/filter:test');
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
-      '/admin/projects,50'
-    );
+    element.filter = undefined;
+    assert.equal(element.computeNavLink(1), '/admin/projects,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
-      '/admin/projects'
-    );
+    assert.equal(element.computeNavLink(-1), '/admin/projects');
 
-    filter = 'plugins/';
+    element.filter = 'plugins/';
     assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:plugins%252F,50'
+      element.computeNavLink(1),
+      '/admin/projects/q/filter:plugins/,50'
     );
   });
 
@@ -95,7 +85,9 @@
     let resolve: (url: string) => void;
     const promise = new Promise(r => (resolve = r));
     element.path = '/admin/projects';
-    sinon.stub(page, 'show').callsFake(r => resolve(r));
+    sinon
+      .stub(testResolver(navigationToken), 'setUrl')
+      .callsFake(r => resolve(r));
 
     element.filter = 'test';
     await element.updateComplete;
@@ -113,19 +105,19 @@
 
   test('next button', async () => {
     element.itemsPerPage = 25;
-    let projects = new Array(26);
+    element.items = new Array(26);
+    element.loading = false;
     await element.updateComplete;
 
-    let loading;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    loading = true;
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    loading = false;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    projects = [];
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    projects = new Array(4);
-    assert.isTrue(element.hideNextArrow(loading, projects));
+    assert.isFalse(element.hideNextArrow());
+    element.loading = true;
+    assert.isTrue(element.hideNextArrow());
+    element.loading = false;
+    assert.isFalse(element.hideNextArrow());
+    element.items = [];
+    assert.isTrue(element.hideNextArrow());
+    element.items = new Array(4);
+    assert.isTrue(element.hideNextArrow());
   });
 
   test('prev button', async () => {
@@ -186,20 +178,40 @@
   test('next/prev links change when path changes', async () => {
     const BRANCHES_PATH = '/path/to/branches';
     const TAGS_PATH = '/path/to/tags';
-    const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
     element.offset = 0;
     element.itemsPerPage = 25;
     element.filter = '';
     element.path = BRANCHES_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${BRANCHES_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
+
     element.path = TAGS_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${TAGS_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
   });
 
   test('computePage', () => {
-    assert.equal(element.computePage(0, 25), 1);
-    assert.equal(element.computePage(50, 25), 3);
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 3);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index a1a1e84..e1e4ca5 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -17,12 +17,11 @@
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
@@ -115,8 +114,6 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -265,8 +262,6 @@
   }
 
   private renderMentionsDropdown() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     return html` <gr-autocomplete-dropdown
       id="mentionsSuggestions"
       .suggestions=${this.suggestions}
@@ -524,8 +519,6 @@
   }
 
   private isMentionsDropdownActive() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return false;
     return (
       this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
     );
@@ -540,10 +533,8 @@
   private computeSpecialCharIndex() {
     const charAtCursor = this.text[this.textarea!.selectionStart - 1];
 
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      if (charAtCursor === '@' && this.specialCharIndex === -1) {
-        this.specialCharIndex = this.getSpecialCharIndex(this.text);
-      }
+    if (charAtCursor === '@' && this.specialCharIndex === -1) {
+      this.specialCharIndex = this.getSpecialCharIndex(this.text);
     }
     if (charAtCursor === ':' && this.specialCharIndex === -1) {
       this.specialCharIndex = this.getSpecialCharIndex(this.text);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index f8ae38c..0400e85 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -7,12 +7,7 @@
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {
-  pressKey,
-  stubFlags,
-  stubRestApi,
-  waitUntil,
-} from '../../../test/test-utils';
+import {pressKey, stubRestApi, waitUntil} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
@@ -31,14 +26,16 @@
       element,
       /* HTML */ `<div id="hiddenText"></div>
         <span id="caratSpan"> </span>
+        <gr-autocomplete-dropdown id="emojiSuggestions" is-hidden="">
+        </gr-autocomplete-dropdown>
         <gr-autocomplete-dropdown
-          id="emojiSuggestions"
+          id="mentionsSuggestions"
           is-hidden=""
-          style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+          role="listbox"
         >
         </gr-autocomplete-dropdown>
         <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea> `,
+        </iron-autogrow-textarea>`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -49,47 +46,6 @@
   });
 
   suite('mention users', () => {
-    setup(async () => {
-      stubFlags('isEnabled').returns(true);
-      element.requestUpdate();
-      await element.updateComplete;
-    });
-
-    test('renders', () => {
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <div id="hiddenText"></div>
-          <span id="caratSpan"> </span>
-          <gr-autocomplete-dropdown
-            id="emojiSuggestions"
-            is-hidden=""
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <gr-autocomplete-dropdown
-            id="mentionsSuggestions"
-            is-hidden=""
-            role="listbox"
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <iron-autogrow-textarea
-            focused=""
-            aria-disabled="false"
-            id="textarea"
-          >
-          </iron-autogrow-textarea>
-        `,
-        {
-          // gr-autocomplete-dropdown sizing seems to vary between local & CI
-          ignoreAttributes: [
-            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
-          ],
-        }
-      );
-    });
-
     test('mentions selector is open when @ is typed & the textarea has focus', async () => {
       // Needed for Safari tests. selectionStart is not updated when text is
       // updated.
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index 28e83ae..b952a3d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -76,6 +76,9 @@
 
     const pairs = this.getLinePairs();
     const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+    const hideFileCommentButton =
+      this.diffPrefs?.show_file_comment_button === false ||
+      this.renderPrefs?.show_file_comment_button === false;
     const body = html`
       <tbody class=${diffClasses(...extras)}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
@@ -92,6 +95,7 @@
               .tabSize=${this.diffPrefs?.tab_size ?? 2}
               .unifiedDiff=${this.isUnifiedDiff()}
               .responsiveMode=${responsiveMode}
+              .hideFileCommentButton=${hideFileCommentButton}
             >
             </gr-diff-row>
           `;
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 6a5933c..ba43eb4 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -14,12 +14,13 @@
   Replacement,
   RunStatus,
 } from '../../api/checks';
-import {PatchSetNumber} from '../../api/rest-api';
+import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
 import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
-import {PROVIDED_FIX_ID} from '../../utils/comment-util';
-import {assert, assertNever} from '../../utils/common-util';
+import {PROVIDED_FIX_ID, UnsavedInfo} from '../../utils/comment-util';
+import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
 import {CheckResult, CheckRun, RunResult} from './checks-model';
 
@@ -86,6 +87,27 @@
   }
 }
 
+function pleaseFixMessage(result: RunResult) {
+  return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
+
+${result.message}`;
+}
+
+export function createPleaseFixComment(result: RunResult): UnsavedInfo {
+  const pointer = result.codePointers?.[0];
+  assertIsDefined(pointer, 'codePointer');
+  return {
+    __unsaved: true,
+    path: pointer.path,
+    patch_set: result.patchset as RevisionPatchSetNum,
+    side: CommentSide.REVISION,
+    line: pointer.range.end_line ?? pointer.range.start_line,
+    range: pointer.range,
+    message: pleaseFixMessage(result),
+    unresolved: true,
+  };
+}
+
 export function createFixAction(
   target: EventTarget,
   result?: RunResult
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 3380637..de9ec5d 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -7,7 +7,18 @@
 import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+
+export const PLUGIN_LIST_ROUTE: Route<AdminViewState> = {
+  urlPattern: /^\/admin\/plugins(\/)?$/,
+  createState: () => {
+    const state: AdminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.PLUGINS,
+    };
+    return state;
+  },
+};
 
 export enum AdminChildView {
   REPOS = 'gr-repo-list',
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
new file mode 100644
index 0000000..c9b7801
--- /dev/null
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {AdminChildView, PLUGIN_LIST_ROUTE} from './admin';
+
+suite('admin view model', () => {
+  suite('routes', () => {
+    test('PLUGIN_LIST', () => {
+      const {urlPattern: pattern, createState} = PLUGIN_LIST_ROUTE;
+
+      assert.isTrue(pattern.test('/admin/plugins'));
+      assert.isTrue(pattern.test('/admin/plugins/'));
+      assert.isFalse(pattern.test('admin/plugins'));
+      assert.isFalse(pattern.test('//admin/plugins'));
+      assert.isFalse(pattern.test('//admin/plugins?'));
+      assert.isFalse(pattern.test('/admin/plugins//'));
+
+      assert.deepEqual(createState({}), {
+        view: GerritView.ADMIN,
+        adminView: AdminChildView.PLUGINS,
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
index 065495d..72bec33 100644
--- a/polygerrit-ui/app/models/views/base.ts
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -8,3 +8,24 @@
 export interface ViewState {
   view: GerritView;
 }
+
+/**
+ * While we are using page.js this interface will normally be implemented by
+ * PageContext, but it helps testing and independence to have our own type
+ * here.
+ */
+export interface UrlInfo {
+  querystring?: string;
+  hash?: string;
+  /** What the regular expression matching returns. */
+  params?: {[paramIndex: string]: string};
+}
+
+/**
+ * Based on `urlPattern` knows whether a URL matches and if so, then
+ * `createState()` can produce a `ViewState` from the matched URL.
+ */
+export interface Route<T extends ViewState> {
+  urlPattern: RegExp;
+  createState: (info: UrlInfo) => T;
+}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index a206037..153777f 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -196,7 +196,7 @@
     childView: ChangeChildView.DIFF,
   });
 
-  const path = `/${encodeURL(state.diffView?.path ?? '', true)}`;
+  const path = `/${encodeURL(state.diffView?.path ?? '')}`;
 
   let suffix = '';
   // TODO: Move creating of comment URLs to a separate function. We are
@@ -226,7 +226,7 @@
     patchNum: obj.patchNum ?? EDIT,
   });
 
-  const path = `/${encodeURL(state.editView?.path ?? '', true)}`;
+  const path = `/${encodeURL(state.editView?.path ?? '')}`;
   const line = state.editView?.lineNum;
   const suffix = line ? `#${line}` : '';
 
@@ -242,7 +242,7 @@
   if (range.length) range = '/' + range;
 
   let repo = '';
-  if (state.repo) repo = `${encodeURL(state.repo, true)}/+/`;
+  if (state.repo) repo = `${encodeURL(state.repo)}/+/`;
 
   return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
 }
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 837e362..1e71bbd 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -74,7 +74,7 @@
       ...createChangeViewState(),
       repo: 'x+/y+/z+/w' as RepoName,
     };
-    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
+    assert.equal(createChangeUrl(state), '/c/x%2B/y%2B/z%2B/w/+/42');
   });
 
   test('createDiffUrl', () => {
@@ -85,7 +85,7 @@
     };
     assert.equal(
       createDiffUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp'
+      '/c/test-project/+/42/12/x%2By/path.cpp'
     );
 
     window.CANONICAL_PATH = '/base';
@@ -93,10 +93,10 @@
     window.CANONICAL_PATH = undefined;
 
     params.repo = 'test' as RepoName;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%2By/path.cpp');
 
     params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%2By/path.cpp');
 
     params.diffView = {
       path: 'foo bar/my+file.txt%',
@@ -105,7 +105,7 @@
     delete params.basePatchNum;
     assert.equal(
       createDiffUrl(params),
-      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+      '/c/test/+/42/2/foo+bar/my%2Bfile.txt%2525'
     );
 
     params.diffView = {
@@ -129,7 +129,7 @@
       repo: 'x+/y' as RepoName,
       diffView: {path: 'x+y/path.cpp'},
     };
-    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/x%2B/y/+/42/12/x%2By/path.cpp');
   });
 
   test('createEditUrl', () => {
@@ -140,7 +140,7 @@
     };
     assert.equal(
       createEditUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+      '/c/test-project/+/42/12/x%2By/path.cpp,edit#31'
     );
 
     window.CANONICAL_PATH = '/base';
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index d9ff2d2..74523db 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -33,7 +33,7 @@
     const query = repoName
       ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
       : section.query;
-    return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+    return encodeURL(section.name) + '=' + encodeURL(query);
   });
 }
 
@@ -43,13 +43,13 @@
     // Custom dashboard.
     const queryParams = sectionsToEncodedParams(state.sections, repoName);
     if (state.title) {
-      queryParams.push('title=' + encodeURIComponent(state.title));
+      queryParams.push('title=' + encodeURL(state.title));
     }
     const user = state.user ? state.user : '';
     return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
   } else if (repoName) {
     // Project dashboard.
-    const encodedRepo = encodeURL(repoName, true);
+    const encodedRepo = encodeURL(repoName);
     return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
   } else {
     // User dashboard.
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 86bb5c0..a7620dd 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -34,7 +34,7 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/?section%201=query%201&section%202=query%202'
+        '/dashboard/?section+1=query+1&section+2=query+2'
       );
     });
 
@@ -48,8 +48,8 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/?section%201=query%201%20repo-name&' +
-          'section%202=query%202%20repo-name'
+        '/dashboard/?section+1=query+1+repo-name&' +
+          'section+2=query+2+repo-name'
       );
     });
 
@@ -61,7 +61,7 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/user?name=query&title=custom%20dashboard'
+        '/dashboard/user?name=query&title=custom+dashboard'
       );
     });
 
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index b564d64..6789695 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -10,7 +10,7 @@
 
 export interface DocumentationViewState extends ViewState {
   view: GerritView.DOCUMENTATION_SEARCH;
-  filter?: string | null;
+  filter: string;
 }
 
 export const documentationViewModelToken = define<DocumentationViewModel>(
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
index 277bcff..2ab3735 100644
--- a/polygerrit-ui/app/models/views/group.ts
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -22,7 +22,7 @@
 }
 
 export function createGroupUrl(state: Omit<GroupViewState, 'view'>) {
-  let url = `/admin/groups/${encodeURL(`${state.groupId}`, true)}`;
+  let url = `/admin/groups/${encodeURL(`${state.groupId}`)}`;
   if (state.detail === GroupDetailView.MEMBERS) {
     url += ',members';
   } else if (state.detail === GroupDetailView.LOG) {
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index ec65ca1..66bf5bf 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -36,7 +36,7 @@
 }
 
 export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
-  let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
+  let url = `/admin/repos/${encodeURL(`${state.repo}`)}`;
   if (state.detail === RepoDetailView.GENERAL) {
     url += ',general';
   } else if (state.detail === RepoDetailView.ACCESS) {
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index c5d394d..48775ce 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -86,42 +86,39 @@
   }
 
   if (params.query) {
-    return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
+    return `${getBaseUrl()}/q/${encodeURL(params.query)}${offsetExpr}`;
   }
 
   const operators: string[] = [];
   if (params.owner) {
-    operators.push('owner:' + encodeURL(params.owner, false));
+    operators.push('owner:' + encodeURL(params.owner));
   }
   if (params.repo) {
-    operators.push('project:' + encodeURL(params.repo, false));
+    operators.push('project:' + encodeURL(params.repo));
   }
   if (params.branch) {
-    operators.push('branch:' + encodeURL(params.branch, false));
+    operators.push('branch:' + encodeURL(params.branch));
   }
   if (params.topic) {
     operators.push(
-      'topic:' +
-        escapeAndWrapSearchOperatorValue(encodeURL(params.topic, false))
+      'topic:' + escapeAndWrapSearchOperatorValue(encodeURL(params.topic))
     );
   }
   if (params.hashtag) {
     operators.push(
       'hashtag:' +
         escapeAndWrapSearchOperatorValue(
-          encodeURL(params.hashtag.toLowerCase(), false)
+          encodeURL(params.hashtag.toLowerCase())
         )
     );
   }
   if (params.statuses) {
     if (params.statuses.length === 1) {
-      operators.push('status:' + encodeURL(params.statuses[0], false));
+      operators.push('status:' + encodeURL(params.statuses[0]));
     } else if (params.statuses.length > 1) {
       operators.push(
         '(' +
-          params.statuses
-            .map(s => `status:${encodeURL(s, false)}`)
-            .join(' OR ') +
+          params.statuses.map(s => `status:${encodeURL(s)}`).join(' OR ') +
           ')'
       );
     }
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
index 6809225..e2e02f5 100644
--- a/polygerrit-ui/app/models/views/search_test.ts
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -56,10 +56,10 @@
 
     // The presence of the query param overrides other options.
     options.query = 'foo$bar';
-    assert.equal(createSearchUrl(options), '/q/foo%2524bar');
+    assert.equal(createSearchUrl(options), '/q/foo%24bar');
 
     options.offset = 100;
-    assert.equal(createSearchUrl(options), '/q/foo%2524bar,100');
+    assert.equal(createSearchUrl(options), '/q/foo%24bar,100');
 
     options = {statuses: ['a', 'b', 'c']};
     assert.equal(
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 2a5dff2..29e9259 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -17,10 +17,7 @@
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
-  DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   SUGGEST_EDIT = 'UiFeature__suggest_edit',
-  MENTION_USERS = 'UiFeature__mention_users',
-  RENDER_MARKDOWN = 'UiFeature__render_markdown',
   REBASE_CHAIN = 'UiFeature__rebase_chain',
 }
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index c3c1cb6..5e2cc10 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -21,12 +21,17 @@
   SETTINGS = 'settings',
 }
 
+// TODO: Consider renaming this to AppElementState or something similar.
+// Or maybe RootViewState. This class does *not* model the state of the router.
 export interface RouterState {
   // Note that this router model view must be updated before view model state.
   view?: GerritView;
 }
 
 export const routerModelToken = define<RouterModel>('router-model');
+
+// TODO: Consider renaming this to AppElementViewModel or something similar.
+// Or maybe RootViewModel. This class is *not* a view model of the router.
 export class RouterModel extends Model<RouterState> {
   readonly routerView$: Observable<GerritView | undefined> = select(
     this.state$,
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index b6e8f60..963b2a2 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -16,13 +16,16 @@
     border-top: 1px solid transparent;
     display: block;
     padding: 0 var(--spacing-xl);
-  }
-  .navStyles li a {
-    display: block;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
   }
+  .navStyles li a {
+    display: block;
+    /* overflow and text-overflow are not inherited, must repeat them */
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
   .navStyles .subsectionItem {
     padding-left: var(--spacing-xxl);
   }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index f0a4cbe..b480bfe 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -116,6 +116,10 @@
 import {SearchViewState} from '../models/views/search';
 import {ChangeChildView, ChangeViewState} from '../models/views/change';
 import {NormalizedFileInfo} from '../models/change/files-model';
+import {GroupViewState} from '../models/views/group';
+import {RepoDetailView, RepoViewState} from '../models/views/repo';
+import {AdminChildView, AdminViewState} from '../models/views/admin';
+import {DashboardViewState} from '../models/views/dashboard';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -741,6 +745,73 @@
   };
 }
 
+export function createSearchViewState(): SearchViewState {
+  return {
+    view: GerritView.SEARCH,
+    query: '',
+    offset: undefined,
+    loading: false,
+    changes: [],
+  };
+}
+
+export function createDashboardViewState(): DashboardViewState {
+  return {
+    view: GerritView.DASHBOARD,
+    user: 'self',
+  };
+}
+
+export function createAdminReposViewState(): AdminViewState {
+  return {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.REPOS,
+    offset: '0',
+    filter: null,
+    openCreateModal: false,
+  };
+}
+
+export function createAdminPluginsViewState(): AdminViewState {
+  return {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.PLUGINS,
+    offset: '0',
+    filter: null,
+  };
+}
+
+export function createGroupViewState(): GroupViewState {
+  return {
+    view: GerritView.GROUP,
+    groupId: 'test-group-id' as GroupId,
+  };
+}
+
+export function createRepoViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+  };
+}
+
+export function createRepoBranchesViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+    detail: RepoDetailView.BRANCHES,
+    offset: '0',
+    filter: null,
+  };
+}
+
+export function createRepoTagsViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+    detail: RepoDetailView.TAGS,
+    offset: '0',
+    filter: null,
+  };
+}
+
 export function createRequirement(): Requirement {
   return {
     status: RequirementStatus.OK,
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 77834bd..4404e59 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -3,7 +3,12 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  DetailedLabelInfo,
+  ServerInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {
   getAccountTemplate,
@@ -13,6 +18,7 @@
 } from './account-util';
 import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
 import {hasOwnProperty} from './common-util';
+import {getCodeReviewLabel} from './label-util';
 
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
@@ -101,9 +107,10 @@
 /**
  *  Sort order:
  * 1. The user themselves
- * 2. Human users in the attention set.
- * 3. Other human users.
- * 4. Service users.
+ * 2. Users in the attention set first.
+ * 3. Human users first.
+ * 4. Users that have voted first in this order of vote values:
+ *    -2, -1, +2, +1, 0 or no vote.
  */
 export function sortReviewers(
   r1: AccountInfo,
@@ -117,7 +124,22 @@
   }
   const a1 = hasAttention(r1, change) ? 1 : 0;
   const a2 = hasAttention(r2, change) ? 1 : 0;
-  const s1 = isServiceUser(r1) ? -2 : 0;
-  const s2 = isServiceUser(r2) ? -2 : 0;
-  return a2 - a1 + s2 - s1;
+  if (a2 - a1 !== 0) return a2 - a1;
+
+  const s1 = isServiceUser(r1) ? -1 : 0;
+  const s2 = isServiceUser(r2) ? -1 : 0;
+  if (s2 - s1 !== 0) return s2 - s1;
+
+  const crLabel = getCodeReviewLabel(change?.labels ?? {}) as DetailedLabelInfo;
+  let v1 =
+    crLabel?.all?.find(vote => vote._account_id === r1._account_id)?.value ?? 0;
+  let v2 =
+    crLabel?.all?.find(vote => vote._account_id === r2._account_id)?.value ?? 0;
+  // We want negative votes getting a higher score than positive votes, so
+  // we choose 10 as a random number that is higher than all positive votes that
+  // are in use, and then add the absolute value of the vote to that.
+  // So -2 becomes 12.
+  if (v1 < 0) v1 = 10 - v1;
+  if (v2 < 0) v2 = 10 - v2;
+  return v2 - v1;
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 8092a6e..5bd1924 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -6,9 +6,11 @@
 import '../test/common-test-setup';
 import {
   createAccountDetailWithIdNameAndEmail,
+  createAccountWithId,
   createChange,
   createComment,
   createCommentThread,
+  createParsedChange,
   createServerInfo,
 } from '../test/test-data-generators';
 import {
@@ -22,9 +24,10 @@
   getMentionedReason,
   getReason,
   hasAttention,
+  sortReviewers,
 } from './attention-set-util';
 import {DefaultDisplayNameConfig} from '../api/rest-api';
-import {AccountsVisibility} from '../constants/constants';
+import {AccountsVisibility, AccountTag} from '../constants/constants';
 import {assert} from '@open-wc/testing';
 
 const KERMIT: AccountInfo = {
@@ -101,6 +104,45 @@
     assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
   });
 
+  test('sortReviewers', () => {
+    const a1 = createAccountWithId(1);
+    a1.tags = [AccountTag.SERVICE_USER];
+    const a2 = createAccountWithId(2);
+    a2.tags = [AccountTag.SERVICE_USER];
+    const a3 = createAccountWithId(3);
+    const a4 = createAccountWithId(4);
+    const a5 = createAccountWithId(5);
+    const a6 = createAccountWithId(6);
+    const a7 = createAccountWithId(7);
+
+    const reviewers = [a1, a2, a3, a4, a5, a6, a7];
+    const change = {
+      ...createParsedChange(),
+      attention_set: {'6': {account: a6}},
+      labels: {
+        'Code-Review': {
+          all: [
+            {...a2, value: 1},
+            {...a4, value: 1},
+            {...a5, value: -1},
+          ],
+        },
+      },
+    };
+    assert.sameOrderedMembers(
+      reviewers.sort((r1, r2) => sortReviewers(r1, r2, change, a7)),
+      [
+        a7, // self
+        a6, // is in the attention set
+        a5, // human user, has voted -1
+        a4, // human user, has voted +1
+        a3, // human user, has not voted
+        a2, // service user, has voted
+        a1, // service user, has not voted
+      ]
+    );
+  });
+
   test('getMentionReason', () => {
     let comment = {
       ...createComment(),
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
index 78e78ed..58bb024 100644
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -16,10 +16,16 @@
   redirect(url: string): void;
   replace(path: string, state: null, init: boolean, dispatch: boolean): void;
   base(url: string): void;
-  start(): void;
+  start(opts: Options): void;
+  stop(): void;
   exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
 }
 
+export interface Options {
+  popstate?: boolean;
+  dispatch?: boolean;
+}
+
 // See https://visionmedia.github.io/page.js/ for details
 export interface PageContext {
   canonicalPath: string;
@@ -37,6 +43,7 @@
   next: PageNextCallback
 ) => void;
 
-// TODO: Convert page usages to the real types and remove this file of wrapper
-// types. Also remove workarounds in rollup config.
-export const page = pagejs as unknown as Page;
+// Must only be used by gr-router!
+// TODO: Move this into gr-router. Note that there is a Google import rule
+// that would need to be modified.
+export const page = pagejs as unknown as {create(): Page};
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 8564c3f..22a9721 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -81,18 +81,56 @@
 }
 
 /**
- * Pretty-encodes a URL. Double-encodes the string, and then replaces
- *   benevolent characters for legibility.
+ * Encodes *parts* of a URL. See inline comments below for the details.
+ * Note specifically that ? & = # are encoded. So this is very close to
+ * encodeURIComponent() with some tweaks.
  */
-export function encodeURL(url: string, replaceSlashes?: boolean): string {
-  // @see Issue 4255 regarding double-encoding.
-  let output = encodeURIComponent(encodeURIComponent(url));
-  // @see Issue 4577 regarding more readable URLs.
-  output = output.replace(/%253A/g, ':');
-  output = output.replace(/%2520/g, '+');
-  if (replaceSlashes) {
-    output = output.replace(/%252F/g, '/');
-  }
+export function encodeURL(url: string): string {
+  // page.js decodes the entire URL, and then decodes once more the
+  // individual regex matching groups. It uses `decodeURIComponent()`, which
+  // will choke on singular `%` chars without two trailing digits. We prefer
+  // to not double encode *everything* (just for readaiblity and simplicity),
+  // but `%` *must* be double encoded.
+  let output = url.replaceAll('%', '%25');
+
+  // This escapes ALL characters EXCEPT:
+  // A–Z a–z 0–9 - _ . ! ~ * ' ( )
+  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
+  output = encodeURIComponent(output);
+
+  // If we would use `encodeURI()` instead of `encodeURIComponent()`, then we
+  // would also NOT encode:
+  // ; / ? : @ & = + $ , #
+  //
+  // That would be more readable, but for example ? and & have special meaning
+  // in the URL, so they must be encoded. Let's discuss all these chars and
+  // decide whether we have to encode them or not.
+  //
+  // ? & = # have to be encoded. Otherwise we might mess up the URL.
+  //
+  // : @ do not have to be encoded, because we are only dealing with path,
+  // query and fragment of the URL, not with scheme, user, host, port.
+  // For search queries it is much nicer to not encode those chars, think of
+  // searching for `owner:spearce@spearce.org`.
+  //
+  // / does not have to be encoded, because we don't care about individual path
+  // components. File path and repo names are so much nicer to read without /
+  // being encoded!
+  //
+  // + must be encoded, because we want to use it instead of %20 for spaces, see
+  // below.
+  //
+  // ; $ , probably don't have to be encoded, but we don't bother about them
+  // much, so we don't reverse the encoding here, but we don't think it would
+  // cause any harm, if we did.
+  output = output.replace(/%3A/g, ':');
+  output = output.replace(/%40/g, '@');
+  output = output.replace(/%2F/g, '/');
+
+  // page.js replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
+  // So we can use `+` to increase readability.
+  output = output.replace(/%20/g, '+');
+
   return output;
 }
 
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index a014dc2..16f85dd 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -102,28 +102,21 @@
 
   suite('url encoding and decoding tests', () => {
     suite('encodeURL', () => {
-      test('double encodes', () => {
-        assert.equal(encodeURL('abc?123'), 'abc%253F123');
-        assert.equal(encodeURL('def/ghi'), 'def%252Fghi');
-        assert.equal(encodeURL('jkl'), 'jkl');
-        assert.equal(encodeURL(''), '');
+      test('does not encode alphanumeric chars', () => {
+        assert.equal(encodeURL("AZaz09-_.!~*'()"), "AZaz09-_.!~*'()");
       });
 
-      test('does not convert colons', () => {
-        assert.equal(encodeURL('mno:pqr'), 'mno:pqr');
+      test('double encodes %', () => {
+        assert.equal(encodeURL('abc%def'), 'abc%2525def');
       });
 
-      test('converts spaces to +', () => {
+      test('does not encode colon and slash', () => {
+        assert.equal(encodeURL(':/'), ':/');
+      });
+
+      test('encodes spaces as +', () => {
         assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
       });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
-      });
     });
 
     suite('singleDecodeUrl', () => {
diff --git a/proto/entities.proto b/proto/entities.proto
index 191cca7..0dc6441 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -98,6 +98,7 @@
   optional string groups = 6;
   optional string push_certificate = 8;
   optional string description = 9;
+  optional Account_Id real_uploader_account_id = 10;
 
   // Deleted fields, should not be reused:
   reserved 5;  // draft
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 98ab4b2..4b621b5 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -77,13 +77,8 @@
       {for $line, $index in $comment.lines}
         {if $index == 0}
           {if $comment.startLine != 0}
-            {$comment.link}
+            {$comment.link}{sp}:{\n}
           {/if}
-
-          // Insert a space before the newline so that Gmail does not mistakenly
-          // link the following line with the file link. See issue 9201.
-          {sp}{\n}
-
           {$comment.linePrefix}
         {else}
           {$comment.linePrefixEmpty}
diff --git a/tools/deps.bzl b/tools/deps.bzl
index ed8d65f5..28904a9 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -2,7 +2,7 @@
 
 CAFFEINE_VERS = "2.9.2"
 ANTLR_VERS = "3.5.2"
-COMMONMARK_VERS = "0.10.0"
+COMMONMARK_VERSION = "0.21.0"
 FLEXMARK_VERS = "0.50.50"
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
@@ -14,7 +14,7 @@
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
-GITILES_VERS = "1.0.0"
+GITILES_VERS = "1.1.0"
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
@@ -172,26 +172,26 @@
     # commonmark must match the version used in Gitiles
     maven_jar(
         name = "commonmark",
-        artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
-        sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
+        artifact = "org.commonmark:commonmark:" + COMMONMARK_VERSION,
+        sha1 = "c98f0473b17c87fe4fa2fc62a7c6523a2fe018f0",
     )
 
     maven_jar(
         name = "cm-autolink",
-        artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
-        sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
+        artifact = "org.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERSION,
+        sha1 = "55c0312cf443fa3d5af0daeeeca00d6deee3cf90",
     )
 
     maven_jar(
         name = "gfm-strikethrough",
-        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
-        sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+        artifact = "org.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERSION,
+        sha1 = "953f4b71e133a98fcca93f3c3f4e58b895b76d1f",
     )
 
     maven_jar(
         name = "gfm-tables",
-        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
-        sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+        artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION,
+        sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a",
     )
 
     maven_jar(
@@ -347,8 +347,8 @@
     # Transitive dependency of flexmark and gitiles
     maven_jar(
         name = "autolink",
-        artifact = "org.nibor.autolink:autolink:0.7.0",
-        sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
+        artifact = "org.nibor.autolink:autolink:0.10.0",
+        sha1 = "6579ea7079be461e5ffa99f33222a632711cc671",
     )
 
     maven_jar(
@@ -527,14 +527,14 @@
         artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
         attach_source = False,
         repository = GITILES_REPO,
-        sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
+        sha1 = "31c1a6e5d92b57bb2f9db24e1032145961c09a8d",
     )
 
     maven_jar(
         name = "gitiles-servlet",
         artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
         repository = GITILES_REPO,
-        sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
+        sha1 = "c6550362c5c22d8e07edd4e2151ee12594082e76",
     )
 
     # prettify must match the version used in Gitiles