Merge "Revert "Ensure that quoted messages show up in the reply comment""
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index ca72f8b..33c5bbd 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -533,6 +533,24 @@
 Certain operations in Gerrit can be validated by plugins by
 implementing the corresponding link:config-validation.html[listeners].
 
+[[taskListeners]]
+== WorkQueue.TaskListeners
+
+It is possible for plugins to listen to
+`com.google.gerrit.server.git.WorkQueue$Task`s directly before they run, and
+directly after they complete. This may be used to delay task executions based
+on custom criteria by blocking, likely on a lock or semaphore, inside
+onStart(), and a lock/semaphore release in onStop(). Plugins may listen to
+tasks by implementing a `com.google.gerrit.server.git.WorkQueue$TaskListener`
+and registering the new listener like this:
+
+[source,java]
+----
+bind(TaskListener.class)
+    .annotatedWith(Exports.named("MyListener"))
+    .to(MyListener.class);
+----
+
 [[change-message-modifier]]
 == Change Message Modifier
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index efc746a..685f9d9 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3804,6 +3804,7 @@
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
+|==================================================
 
 [[commentlink-input]]
 === CommentLinkInput
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 1177734..c5d319f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -32,6 +32,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -89,6 +90,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -98,6 +100,7 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -1100,6 +1103,42 @@
     gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
+  protected void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
+    for (TestAccount testAccount : testAccounts) {
+      assertThrows(
+          ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
+    }
+  }
+
+  protected void assertReviewers(String changeId, TestAccount... expectedReviewers)
+      throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(reviewers)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertNoCcs(String changeId) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).doesNotContainKey(ReviewerState.CC);
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
     assertSubmittedTogether(chId, ImmutableSet.of(), expected);
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 11999ab..3f51872 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.gerrit.extensions.client.Comment;
@@ -154,7 +155,11 @@
   }
 
   public ReviewInput reviewer(String reviewer) {
-    return reviewer(reviewer, REVIEWER, false);
+    return reviewer(reviewer, REVIEWER, /* confirmed= */ false);
+  }
+
+  public ReviewInput cc(String cc) {
+    return reviewer(cc, CC, /* confirmed= */ false);
   }
 
   public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index c4e185d..762d988 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.options.AutoFlush;
@@ -175,6 +176,8 @@
     }
     boolean replica = ReplicaUtil.isReplica(globalConfig);
     List<Module> modules = new ArrayList<>();
+    modules.add(new WorkQueueModule());
+
     Module indexModule;
     IndexType indexType = IndexModule.getIndexType(dbInjector);
     if (indexType.isLucene()) {
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 8824d56..65eb332 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -559,6 +559,11 @@
     return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::allVisible);
   }
 
+  public Result resolveIncludeInactiveIgnoreVisibility(String input)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::allVisible);
+  }
+
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
     return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive);
   }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index edaca70..46f307d 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -275,14 +275,29 @@
         Iterables.transform(ccs, Account.Id::toString));
   }
 
+  public ChangeInserter setReviewersAndCcsIgnoreVisibility(
+      Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
+    return setReviewersAndCcsAsStrings(
+        Iterables.transform(reviewers, Account.Id::toString),
+        Iterables.transform(ccs, Account.Id::toString),
+        /* skipVisibilityCheck= */ true);
+  }
+
   public ChangeInserter setReviewersAndCcsAsStrings(
       Iterable<String> reviewers, Iterable<String> ccs) {
+    return setReviewersAndCcsAsStrings(reviewers, ccs, /* skipVisibilityCheck= */ false);
+  }
+
+  private ChangeInserter setReviewersAndCcsAsStrings(
+      Iterable<String> reviewers, Iterable<String> ccs, boolean skipVisibilityCheck) {
     reviewerInputs =
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER, skipVisibilityCheck)),
+                Streams.stream(ccs)
+                    .distinct()
+                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck)))
             .collect(toImmutableList());
     return this;
   }
@@ -595,7 +610,8 @@
     }
   }
 
-  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(
+      String reviewer, ReviewerState state, boolean skipVisibilityCheck) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
     InternalReviewerInput input =
@@ -606,7 +622,9 @@
     // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
     // theory we could provide finer control to do this for some reviewers and not others, but it's
     // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+
+    input.skipVisibilityCheck = skipVisibilityCheck;
 
     return input;
   }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 9580565..f3ad4f7 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -94,9 +94,21 @@
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
+  /**
+   * Controls which failures should be ignored.
+   *
+   * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
+   * ignored a failure means that the operation fails.
+   */
   public enum FailureBehavior {
+    // All failures cause the operation to fail.
     FAIL,
-    IGNORE;
+
+    // Only not found failures cause the operation to fail, all other failures are ignored.
+    IGNORE_EXCEPT_NOT_FOUND,
+
+    // All failures are ignored.
+    IGNORE_ALL;
   }
 
   private enum FailureType {
@@ -113,6 +125,9 @@
      * resolving to an account/group/email.
      */
     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
+
+    /** Whether the visibility check for the reviewer account should be skipped. */
+    public boolean skipVisibilityCheck = false;
   }
 
   public static InternalReviewerInput newReviewerInput(
@@ -143,7 +158,7 @@
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
-    in.otherFailureBehavior = FailureBehavior.IGNORE;
+    in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
     return Optional.of(in);
   }
 
@@ -262,7 +277,13 @@
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      if (input instanceof InternalReviewerInput
+          && ((InternalReviewerInput) input).skipVisibilityCheck) {
+        reviewerUser =
+            accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
+      } else {
+        reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      }
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
@@ -577,7 +598,9 @@
           (input instanceof InternalReviewerInput)
               ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
-      return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
+      return behavior == FailureBehavior.IGNORE_ALL
+          || (failureType == FailureType.OTHER
+              && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index fa46bf4..f90daff 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -286,7 +286,7 @@
     reviewers.remove(user.getAccountId());
     Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
     ccs.remove(user.getAccountId());
-    ins.setReviewersAndCcs(reviewers, ccs);
+    ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
     ins.setWorkInProgress(input.workInProgress);
 
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 3032bfe..715cb30 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -20,6 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -27,6 +28,7 @@
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.LoggingContextAwareRunnable;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -58,6 +60,30 @@
 public class WorkQueue {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * To register a TaskListener, which will be called directly before Tasks run, and directly after
+   * they complete, bind the TaskListener like this:
+   *
+   * <p><code>
+   *   bind(TaskListener.class)
+   *       .annotatedWith(Exports.named("MyListener"))
+   *       .to(MyListener.class);
+   * </code>
+   */
+  public interface TaskListener {
+    public static class NoOp implements TaskListener {
+      @Override
+      public void onStart(Task<?> task) {}
+
+      @Override
+      public void onStop(Task<?> task) {}
+    }
+
+    void onStart(Task<?> task);
+
+    void onStop(Task<?> task);
+  }
+
   public static class Lifecycle implements LifecycleListener {
     private final WorkQueue workQueue;
 
@@ -78,6 +104,7 @@
   public static class WorkQueueModule extends LifecycleModule {
     @Override
     protected void configure() {
+      DynamicMap.mapOf(binder(), WorkQueue.TaskListener.class);
       bind(WorkQueue.class);
       listener().to(Lifecycle.class);
     }
@@ -87,18 +114,32 @@
   private final IdGenerator idGenerator;
   private final MetricMaker metrics;
   private final CopyOnWriteArrayList<Executor> queues;
+  private final PluginMapContext<TaskListener> listeners;
 
   @Inject
-  WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg, MetricMaker metrics) {
-    this(idGenerator, Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2), metrics);
+  WorkQueue(
+      IdGenerator idGenerator,
+      @GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
+    this(
+        idGenerator,
+        Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2),
+        metrics,
+        listeners);
   }
 
   /** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
-  public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize, MetricMaker metrics) {
+  public WorkQueue(
+      IdGenerator idGenerator,
+      int defaultThreadPoolSize,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
     this.idGenerator = idGenerator;
     this.metrics = metrics;
     this.queues = new CopyOnWriteArrayList<>();
     this.defaultQueue = createQueue(defaultThreadPoolSize, "WorkQueue", true);
+    this.listeners = listeners;
   }
 
   /** Get the default work queue, for miscellaneous tasks. */
@@ -438,6 +479,14 @@
     Collection<Task<?>> getTasks() {
       return all.values();
     }
+
+    public void onStart(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStart(task));
+    }
+
+    public void onStop(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStop(task));
+    }
   }
 
   private static void logUncaughtException(Thread t, Throwable e) {
@@ -608,10 +657,12 @@
       if (running.compareAndSet(false, true)) {
         String oldThreadName = Thread.currentThread().getName();
         try {
+          executor.onStart(this);
           Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
+          executor.onStop(this);
           if (isPeriodic()) {
             running.set(false);
           } else {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 644f82e..ae7c3a10 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -405,7 +405,7 @@
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
 
     return input;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 00c48dc..d0113e5 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -527,7 +527,7 @@
       reviewers.remove(user.get().getAccountId());
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.get().getAccountId());
-      ins.setReviewersAndCcs(reviewers, ccs);
+      ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     }
     // If there is a base, and the base is not merged, the groups will be overridden by the base's
     // groups.
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b00cadb..781965e 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl.SearchingChangeCacheImplModule;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
@@ -195,6 +196,7 @@
     install(new AuditModule());
     install(new SubscriptionGraphModule());
     install(new SuperprojectUpdateSubmissionListenerModule());
+    install(new WorkQueueModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 90ca047..93ac038 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -424,6 +425,46 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void revertWithNonVisibleUsers() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount reverter = user;
+    TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the reverter can neither see the changeOwner, the reviewer nor the cc.
+    requestScopeOperations.setApiUser(reverter.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc);
+
+    // Create the change.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit.Result r = createChange();
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Revert the change.
+    requestScopeOperations.setApiUser(reverter.id());
+    String revertChangeId = gApi.changes().id(r.getChangeId()).revert().get().id;
+
+    // Revert doesn't check the reviewer/CC visibility. Since the reverter can see the reverted
+    // change, they can also see its reviewers/CCs. This means preserving them on the revert change
+    // doesn't expose their account existence and it's OK to keep them even if their accounts are
+    // not visible to the reverter.
+    assertReviewers(revertChangeId, changeOwner, reviewer);
+    assertCcs(revertChangeId, cc);
+  }
+
+  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void revertInitialCommit() throws Exception {
     PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2c80333..8d64cf7 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1009,6 +1009,84 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void cherryPickWithNonVisibleUsers() throws Exception {
+    // Create a target branch for the cherry-pick.
+    createBranch(BranchNameKey.create(project, "stable"));
+
+    // Define readable names for the users we use in this test.
+    TestAccount cherryPicker = user;
+    TestAccount changeOwner = admin;
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+    TestAccount authorCommitter =
+        accountCreator.create("user4", "user4@example.com", "User4", /* displayName= */ null);
+
+    // Check that the cherry-picker can neither see the changeOwner, the reviewer, the cc nor the
+    // authorCommitter.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc, authorCommitter);
+
+    // Create the change with authorCommitter as the author and the committer.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit push = pushFactory.create(authorCommitter.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that authorCommitter was set as the author and committer.
+    ChangeInfo changeInfo = gApi.changes().id(r.getChangeId()).get();
+    CommitInfo commit = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(authorCommitter.email());
+    assertThat(commit.committer.email).isEqualTo(authorCommitter.email());
+
+    // Pushing a commit with a forged author/committer adds the author/committer as a CC.
+    assertCcs(r.getChangeId(), authorCommitter);
+
+    // Remove the author/committer as a CC because because otherwise there are two signals for CCing
+    // authorCommitter on the cherry-pick change: once because they are author and committer and
+    // once because they are a CC. For authorCommitter we only want to test the first signal here
+    // (the second signal is covered by adding an explicit CC below).
+    gApi.changes().id(r.getChangeId()).reviewer(authorCommitter.email()).remove();
+    assertNoCcs(r.getChangeId());
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Cherry-pick the change.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.message = "Cherry-pick to stable branch";
+    cherryPickInput.destination = "stable";
+    cherryPickInput.keepReviewers = true;
+    String cherryPickChangeId =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput).get().id;
+
+    // Cherry-pick doesn't check the visibility of explicit reviewers/CCs. Since the cherry-picker
+    // can see the cherry-picked change, they can also see its reviewers/CCs. This means preserving
+    // them on the cherry-pick change doesn't expose their account existence and it's OK to keep
+    // them even if their accounts are not visible to the cherry-picker.
+    // In contrast to this for implicit CCs that are added for the author/committer the account
+    // visibility is checked, but if their accounts are not visible the CC is silently dropped (so
+    // that the cherry-pick request can still succeed). Since in this case authorCommitter is not
+    // visible, we expect that CCing them is being dropped and hence authorCommitter is not returned
+    // as a CC here. The reason that the visibility for author/committer must be checked is that
+    // author/committer may not match a Gerrit account (if they are forged). This means by seeing
+    // the author/committer on the cherry-picked change, it's not possible to deduce that these
+    // Gerrit accounts exists, but if they would be added as a CC on the cherry-pick change even if
+    // they are not visible the account existence would be exposed.
+    assertReviewers(cherryPickChangeId, changeOwner, reviewer);
+    assertCcs(cherryPickChangeId, cc);
+  }
+
+  @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
     createBranch(BranchNameKey.create(project, "foo"));
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cd1d911..3b94a50 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -128,6 +128,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
@@ -1154,7 +1155,7 @@
             .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
             .message(PushOneCommit.SUBJECT)
             .create();
-    // Push commit as "Admnistrator".
+    // Push commit as "Administrator".
     pushHead(testRepo, "refs/for/master");
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
@@ -1168,6 +1169,71 @@
   }
 
   @Test
+  public void pushForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(new PersonIdent("author", "author@example.com"))
+            .committer(new PersonIdent("committer", "committer@example.com"))
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Administrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .author(author.newIdent())
+            .committer(committer.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+
+    PushResult r = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
   public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
     TestAccount user2 = accountCreator.user2();
     // First patch set has author and committer matching change owner.
@@ -1192,6 +1258,74 @@
         .containsExactly(user.getNameEmail(), user2.getNameEmail());
   }
 
+  @Test
+  public void pushNewPatchSetForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+    // First patch set has author and committer matching change owner.
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    amendBuilder()
+        .author(new PersonIdent("author", "author@example.com"))
+        .committer(new PersonIdent("committer", "committer@example.com"))
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+    pushHead(testRepo, "refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushNewPatchSetForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // First patch set has author and committer matching uploader.
+    PushOneCommit push = pushFactory.create(uploader.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    testRepo
+        .amendRef("HEAD")
+        .author(author.newIdent())
+        .committer(committer.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+
+    PushResult r2 = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r2.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
   /**
    * There was a bug that allowed a user with Forge Committer Identity access right to upload a
    * commit and put *votes on behalf of another user* on it. This test checks that this is not
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
new file mode 100644
index 0000000..b3094ac
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -0,0 +1,255 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskListenerIT extends AbstractDaemonTest {
+  /**
+   * Use a LatchedMethod in a method to allow another thread to await the method's call. Once
+   * called, the Latch.call() method will block until another thread calls its LatchedMethods's
+   * complete() method.
+   */
+  private static class LatchedMethod {
+    private static final int AWAIT_TIMEOUT = 20;
+    private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+
+    /** API class meant be used by the class whose method is being latched */
+    private class Latch {
+      /** Ensure that the latched method calls this on entry */
+      public void call() {
+        called.countDown();
+        await(complete);
+      }
+    }
+
+    public Latch latch = new Latch();
+
+    private CountDownLatch called = new CountDownLatch(1);
+    private CountDownLatch complete = new CountDownLatch(1);
+
+    /** Assert that the Latch's call() method has not yet been called */
+    public void assertUncalled() {
+      assertThat(called.getCount()).isEqualTo(1);
+    }
+
+    /**
+     * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
+     * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+     */
+    public void assertAwait() {
+      assertThat(await(called)).isEqualTo(true);
+    }
+
+    /** Unblock the Latch's call() method so that it can complete */
+    public void complete() {
+      complete.countDown();
+    }
+
+    private static boolean await(CountDownLatch latch) {
+      try {
+        return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+      } catch (InterruptedException e) {
+        return false;
+      }
+    }
+  }
+
+  private static class LatchedRunnable implements Runnable {
+    public LatchedMethod run = new LatchedMethod();
+
+    @Override
+    public void run() {
+      run.latch.call();
+    }
+  }
+
+  private static class ForwardingListener implements TaskListener {
+    public volatile TaskListener delegate;
+    public volatile Task task;
+
+    public void resetDelegate(TaskListener listener) {
+      delegate = listener;
+      task = null;
+    }
+
+    @Override
+    public void onStart(Task<?> task) {
+      if (delegate != null) {
+        if (this.task == null || this.task == task) {
+          this.task = task;
+          delegate.onStart(task);
+        }
+      }
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      if (delegate != null) {
+        if (this.task == task) {
+          delegate.onStop(task);
+        }
+      }
+    }
+  }
+
+  private static class LatchedListener implements TaskListener {
+    public LatchedMethod onStart = new LatchedMethod();
+    public LatchedMethod onStop = new LatchedMethod();
+
+    @Override
+    public void onStart(Task<?> task) {
+      onStart.latch.call();
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      onStop.latch.call();
+    }
+  }
+
+  private static ForwardingListener forwarder;
+
+  @Inject private WorkQueue workQueue;
+  private ScheduledExecutorService executor;
+
+  private LatchedListener listener = new LatchedListener();
+  private LatchedRunnable runnable = new LatchedRunnable();
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        // Forwarder.delegate is empty on start to protect test listener from non test tasks
+        // (such as the "Log File Compressor") interference
+        forwarder = new ForwardingListener(); // Only gets bound once for all tests
+        bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
+      }
+    };
+  }
+
+  @Before
+  public void setupExecutorAndForwarder() throws InterruptedException {
+    executor = workQueue.createQueue(1, "TaskListeners");
+
+    // "Log File Compressor"s are likely running and will interfere with tests
+    while (0 != workQueue.getTasks().size()) {
+      for (Task<?> t : workQueue.getTasks()) {
+        t.cancel(true);
+      }
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+
+    forwarder.resetDelegate(listener);
+
+    assertQueueSize(0);
+    assertThat(forwarder.task).isEqualTo(null);
+    listener.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    listener.onStop.assertUncalled();
+  }
+
+  @Test
+  public void onStartThenRunThenOnStopAreCalled() throws Exception {
+    int size = assertQueueBlockedOnExecution(runnable);
+
+    // onStartThenRunThenOnStopAreCalled -> onStart...Called
+    listener.onStart.assertAwait();
+    assertQueueSize(size);
+    runnable.run.assertUncalled();
+    listener.onStop.assertUncalled();
+
+    listener.onStart.complete();
+    // onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
+    runnable.run.assertAwait();
+    listener.onStop.assertUncalled();
+
+    runnable.run.complete();
+    // onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
+    listener.onStop.assertAwait();
+    assertQueueSize(size);
+
+    listener.onStop.complete();
+    assertAwaitQueueSize(--size);
+  }
+
+  @Test
+  public void firstBlocksSecond() throws Exception {
+    int size = assertQueueBlockedOnExecution(runnable);
+
+    // firstBlocksSecond -> first...
+    listener.onStart.assertAwait();
+    assertQueueSize(size);
+
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    size = assertQueueBlockedOnExecution(runnable2);
+
+    // firstBlocksSecond -> ...BlocksSecond
+    runnable2.run.assertUncalled();
+    assertQueueSize(size); // waiting on first
+
+    listener.onStart.complete();
+    runnable.run.assertAwait();
+    assertQueueSize(size); // waiting on first
+    runnable2.run.assertUncalled();
+
+    runnable.run.complete();
+    listener.onStop.assertAwait();
+    assertQueueSize(size); // waiting on first
+    runnable2.run.assertUncalled();
+
+    listener.onStop.complete();
+    runnable2.run.assertAwait();
+    assertQueueSize(--size);
+
+    runnable2.run.complete();
+    assertAwaitQueueSize(--size);
+  }
+
+  private int assertQueueBlockedOnExecution(Runnable runnable) {
+    int expectedSize = workQueue.getTasks().size() + 1;
+    executor.execute(runnable);
+    assertQueueSize(expectedSize);
+    return expectedSize;
+  }
+
+  private void assertQueueSize(int size) {
+    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+  }
+
+  /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+  private void assertAwaitQueueSize(int size) throws InterruptedException {
+    long i = 0;
+    do {
+      TimeUnit.NANOSECONDS.sleep(10);
+      assertThat(i++).isLessThan(100);
+    } while (size != workQueue.getTasks().size());
+  }
+}
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 f2977dc..8293daa 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
@@ -2252,8 +2252,6 @@
     if (tab === Tab.CHECKS) {
       const state: ChecksTabState = {};
       detail.tabState = {checksTab: state};
-      if (this.viewState?.filter) state.filter = this.viewState.filter;
-      if (this.viewState?.attempt) state.attempt = this.viewState.attempt;
     }
 
     this.setActiveTab(
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index a4e026b..11fe0e2 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -28,8 +28,8 @@
 import {pluralize} from '../../../utils/string-util';
 import {
   changeIsOpen,
+  getChangeNumber,
   getRevisionKey,
-  isChangeInfo,
 } from '../../../utils/change-util';
 import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
 import {createChangeUrl} from '../../../models/views/change';
@@ -639,29 +639,12 @@
     a?: ChangeInfo | RelatedChangeAndCommitInfo,
     b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
   ) {
-    const aNum = this._getChangeNumber(a);
-    const bNum = this._getChangeNumber(b);
+    if (!a || !b) return false;
+    const aNum = getChangeNumber(a);
+    const bNum = getChangeNumber(b);
     return aNum === bNum;
   }
 
-  /**
-   * Get the change number from either a ChangeInfo (such as those included in
-   * SubmittedTogetherInfo responses) or get the change number from a
-   * RelatedChangeAndCommitInfo (such as those included in a
-   * RelatedChangesInfo response).
-   */
-  _getChangeNumber(
-    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
-  ) {
-    // Default to 0 if change property is not defined.
-    if (!change) return 0;
-
-    if (isChangeInfo(change)) {
-      return change._number;
-    }
-    return change._change_number;
-  }
-
   /*
    * A list of commit ids connected to change to understand if other change
    * is direct or indirect ancestor / descendant.
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 09cdf5f..dcc6039 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -36,6 +36,7 @@
   SubmittedTogetherInfo,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
+import {getChangeNumber} from '../../../utils/change-util';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import './gr-related-changes-list';
@@ -359,8 +360,8 @@
       change_id: '456' as ChangeId,
       _number: 1 as NumericChangeId,
     };
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
+    assert.equal(getChangeNumber(change1), 0);
+    assert.equal(getChangeNumber(change2), 1);
   });
 
   suite('get conflicts tests', () => {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 26fa0cb..a4e26fe 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -1246,13 +1246,16 @@
   }
 
   private onPatchsetSelected(e: CustomEvent<{value: string}>) {
-    const patchset = Number(e.detail.value);
-    assert(!isNaN(patchset), 'selected patchset must be a number');
-    this.getChecksModel().setPatchset(patchset as PatchSetNumber);
+    let patchset: number | undefined = Number(e.detail.value);
+    assert(Number.isInteger(patchset), `patchset must be integer: ${patchset}`);
+    if (patchset === this.latestPatchsetNumber) patchset = undefined;
+    this.getChecksModel().updateStateSetPatchset(
+      patchset as PatchSetNumber | undefined
+    );
   }
 
   private goToLatestPatchset() {
-    this.getChecksModel().setPatchset(undefined);
+    this.getChecksModel().updateStateSetPatchset(undefined);
   }
 
   private createAttemptDropdownItems() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index c72bccb..82893bc 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -3,7 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, css, html, PropertyValues} from 'lit';
+import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   CheckResult,
@@ -22,7 +22,6 @@
 import {Interaction} from '../../constants/reporting';
 import {resolve} from '../../models/dependency';
 import {GrChecksRuns} from './gr-checks-runs';
-import {LATEST_ATTEMPT} from '../../models/checks/checks-util';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -147,18 +146,6 @@
     `;
   }
 
-  protected override updated(changedProperties: PropertyValues) {
-    super.updated(changedProperties);
-    if (changedProperties.has('tabState')) this.tabStateUpdated();
-  }
-
-  private tabStateUpdated() {
-    if (!this.tabState?.checksTab) return;
-    const {attempt, filter} = this.tabState.checksTab;
-    this.getChecksModel().updateStateSetAttempt(attempt ?? LATEST_ATTEMPT);
-    this.getChecksModel().updateStateSetRunFilter(filter ?? '');
-  }
-
   handleRunSelected(e: RunSelectedEvent) {
     this.reporting.reportInteraction(Interaction.CHECKS_RUN_SELECTED, {
       checkName: e.detail.checkName,
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 762f6b7..a35ca40 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -21,6 +21,7 @@
   RepoName,
   UrlEncodedCommentId,
   PARENT,
+  PatchSetNumber,
 } from '../../../types/common';
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
@@ -59,7 +60,11 @@
   GroupViewState,
 } from '../../../models/views/group';
 import {DiffViewModel, DiffViewState} from '../../../models/views/diff';
-import {ChangeViewModel, ChangeViewState} from '../../../models/views/change';
+import {
+  ChangeViewModel,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
 import {EditViewModel, EditViewState} from '../../../models/views/edit';
 import {
   DashboardViewModel,
@@ -80,6 +85,7 @@
 import {PluginViewModel, PluginViewState} from '../../../models/views/plugin';
 import {SearchViewModel, SearchViewState} from '../../../models/views/search';
 import {DashboardSection} from '../../../utils/dashboard-util';
+import {Subscription} from 'rxjs';
 
 const RoutePattern = {
   ROOT: '/',
@@ -279,6 +285,10 @@
   // and for first navigation in app after loaded from server (true).
   _isInitialLoad = true;
 
+  private subscriptions: Subscription[] = [];
+
+  private view?: GerritView;
+
   constructor(
     private readonly reporting: ReportingService,
     private readonly routerModel: RouterModel,
@@ -295,9 +305,36 @@
     private readonly repoViewModel: RepoViewModel,
     private readonly searchViewModel: SearchViewModel,
     private readonly settingsViewModel: SettingsViewModel
-  ) {}
+  ) {
+    this.subscriptions = [
+      // TODO: Do the same for other view models.
+      // We want to make sure that the current view model state is always
+      // reflected back into the URL bar.
+      this.changeViewModel.state$.subscribe(state => {
+        if (!state) return;
+        // Note that router model view must be updated before view model state.
+        // So this check is slightly fragile, but should work.
+        if (this.view !== GerritView.CHANGE) return;
+        const browserUrl = window.location.toString();
+        const stateUrl = new URL(createChangeUrl(state), browserUrl).toString();
+        if (browserUrl !== stateUrl) {
+          page.replace(
+            stateUrl,
+            null,
+            /* init: */ false,
+            /* dispatch: */ false
+          );
+        }
+      }),
+      this.routerModel.routerView$.subscribe(view => (this.view = view)),
+    ];
+  }
 
-  finalize(): void {}
+  finalize(): void {
+    for (const subscription of this.subscriptions) {
+      subscription.unsubscribe();
+    }
+  }
 
   start() {
     if (!this._app) {
@@ -940,6 +977,7 @@
           view: GerritView.DASHBOARD,
           user: ctx.params[0],
         };
+        // Note that router model view must be updated before view models.
         this.setState(state);
         this.dashboardViewModel.setState(state);
       }
@@ -976,6 +1014,7 @@
       sections,
       title,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.dashboardViewModel.setState(state);
     return Promise.resolve();
@@ -988,6 +1027,7 @@
       project,
       dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.dashboardViewModel.setState(state);
     this.reporting.setRepoName(project);
@@ -1010,6 +1050,7 @@
       view: GerritView.GROUP,
       groupId: ctx.params[0] as GroupId,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.groupViewModel.setState(state);
   }
@@ -1020,6 +1061,7 @@
       detail: GroupDetailView.LOG,
       groupId: ctx.params[0] as GroupId,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.groupViewModel.setState(state);
   }
@@ -1030,6 +1072,7 @@
       detail: GroupDetailView.MEMBERS,
       groupId: ctx.params[0] as GroupId,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.groupViewModel.setState(state);
   }
@@ -1042,6 +1085,7 @@
       filter: null,
       openCreateModal: ctx.hash === 'create',
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.adminViewModel.setState(state);
   }
@@ -1053,6 +1097,7 @@
       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);
   }
@@ -1063,6 +1108,7 @@
       adminView: AdminChildView.GROUPS,
       filter: ctx.params['filter'] || null,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.adminViewModel.setState(state);
   }
@@ -1086,6 +1132,7 @@
       detail: RepoDetailView.COMMANDS,
       repo,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
@@ -1098,6 +1145,7 @@
       detail: RepoDetailView.GENERAL,
       repo,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
@@ -1110,6 +1158,7 @@
       detail: RepoDetailView.ACCESS,
       repo,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
@@ -1122,6 +1171,7 @@
       detail: RepoDetailView.DASHBOARDS,
       repo,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
@@ -1135,6 +1185,7 @@
       offset: ctx.params[2] || 0,
       filter: null,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
@@ -1147,6 +1198,7 @@
       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);
   }
@@ -1158,6 +1210,7 @@
       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);
   }
@@ -1170,6 +1223,7 @@
       offset: ctx.params[2] || 0,
       filter: null,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
@@ -1182,6 +1236,7 @@
       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);
   }
@@ -1193,6 +1248,7 @@
       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);
   }
@@ -1205,6 +1261,7 @@
       filter: null,
       openCreateModal: ctx.hash === 'create',
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.adminViewModel.setState(state);
   }
@@ -1216,6 +1273,7 @@
       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);
   }
@@ -1226,6 +1284,7 @@
       adminView: AdminChildView.REPOS,
       filter: ctx.params['filter'] || null,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.adminViewModel.setState(state);
   }
@@ -1253,6 +1312,7 @@
       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);
   }
@@ -1264,6 +1324,7 @@
       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);
   }
@@ -1274,6 +1335,7 @@
       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);
   }
@@ -1283,6 +1345,7 @@
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.adminViewModel.setState(state);
   }
@@ -1293,6 +1356,7 @@
       query: ctx.params[0],
       offset: ctx.params[2],
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.searchViewModel.setState(state);
   }
@@ -1305,6 +1369,7 @@
       view: GerritView.SEARCH,
       query: ctx.params[0],
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.searchViewModel.setState(state);
   }
@@ -1329,26 +1394,15 @@
     };
 
     const queryMap = new URLSearchParams(ctx.querystring);
-    if (queryMap.has('forceReload')) {
-      state.forceReload = true;
-      history.replaceState(
-        null,
-        '',
-        location.href.replace(/[?&]forceReload=true/, '')
-      );
-    }
-
-    if (queryMap.has('openReplyDialog')) {
-      state.openReplyDialog = true;
-      history.replaceState(
-        null,
-        '',
-        location.href.replace(/[?&]openReplyDialog=true/, '')
-      );
-    }
+    if (queryMap.has('forceReload')) state.forceReload = true;
+    if (queryMap.has('openReplyDialog')) state.openReplyDialog = true;
 
     const tab = queryMap.get('tab');
     if (tab) state.tab = tab;
+    const checksPatchset = Number(queryMap.get('checksPatchset'));
+    if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
+      state.checksPatchset = checksPatchset as PatchSetNumber;
+    }
     const filter = queryMap.get('filter');
     if (filter) state.filter = filter;
     const attempt = stringToAttemptChoice(queryMap.get('attempt'));
@@ -1358,6 +1412,7 @@
     this.reporting.setRepoName(state.project);
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.changeViewModel.setState(state);
   }
@@ -1374,6 +1429,7 @@
     this.reporting.setRepoName(state.project ?? '');
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.diffViewModel.setState(state);
   }
@@ -1390,6 +1446,7 @@
     this.reporting.setRepoName(state.project);
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.changeViewModel.setState(state);
   }
@@ -1413,6 +1470,7 @@
     this.reporting.setRepoName(state.project ?? '');
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.diffViewModel.setState(state);
   }
@@ -1452,6 +1510,7 @@
       view: GerritView.EDIT,
     };
     this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.editViewModel.setState(state);
     this.reporting.setRepoName(project);
@@ -1480,6 +1539,7 @@
       );
     }
     this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.changeViewModel.setState(state);
     this.reporting.setRepoName(project);
@@ -1494,6 +1554,7 @@
     const state: AgreementViewState = {
       view: GerritView.AGREEMENTS,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.agreementViewModel.setState(state);
   }
@@ -1507,12 +1568,14 @@
       view: GerritView.SETTINGS,
       emailToken: token,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.settingsViewModel.setState(state);
   }
 
   handleSettingsRoute(_: PageContext) {
     const state: SettingsViewState = {view: GerritView.SETTINGS};
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.settingsViewModel.setState(state);
   }
@@ -1558,6 +1621,7 @@
       plugin: ctx.params[0],
       screen: ctx.params[1],
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.pluginViewModel.setState(state);
   }
@@ -1567,6 +1631,7 @@
       view: GerritView.DOCUMENTATION_SEARCH,
       filter: ctx.params['filter'] || null,
     };
+    // Note that router model view must be updated before view models.
     this.setState(state);
     this.documentationViewModel.setState(state);
   }
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 ad2daf5..721349f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -508,14 +508,22 @@
     if (isUnsaved(this.comment) && !this.editing) return;
     const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
     return html`
-      <div id="container" class=${classMap(classes)}>
-        ${this.renderHeader()}
-        <div class="body">
-          ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
-          ${this.renderCommentMessage()} ${this.renderHumanActions()}
-          ${this.renderRobotActions()} ${this.renderSuggestEditActions()}
+      <gr-endpoint-decorator name="comment">
+        <gr-endpoint-param name="comment" .value=${this.comment}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="editing" .value=${this.editing}>
+        </gr-endpoint-param>
+        <div id="container" class=${classMap(classes)}>
+          ${this.renderHeader()}
+          <div class="body">
+            ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+            ${this.renderCommentMessage()}
+            <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+            ${this.renderHumanActions()} ${this.renderRobotActions()}
+            ${this.renderSuggestEditActions()}
+          </div>
         </div>
-      </div>
+      </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
     `;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 5686c6b..1b48658 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -92,26 +92,32 @@
       assert.shadowDom.equal(
         initiallyCollapsedElement,
         /* HTML */ `
-          <div class="container" id="container">
-            <div class="header" id="header">
-              <div class="headerLeft">
-                <gr-account-label deselected=""></gr-account-label>
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-account-label deselected=""></gr-account-label>
+                </div>
+                <div class="headerMiddle">
+                  <span class="collapsedContent">
+                    This is the test comment message.
+                  </span>
+                </div>
+                <span class="patchset-text">Patchset 1</span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Expand" class="show-hide">
+                    <input checked="" class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_more"></gr-icon>
+                  </label>
+                </div>
               </div>
-              <div class="headerMiddle">
-                <span class="collapsedContent">
-                  This is the test comment message.
-                </span>
-              </div>
-              <span class="patchset-text">Patchset 1</span>
-              <div class="show-hide" tabindex="0">
-                <label aria-label="Expand" class="show-hide">
-                  <input checked="" class="show-hide" type="checkbox" />
-                  <gr-icon id="icon" icon="expand_more"></gr-icon>
-                </label>
+              <div class="body">
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
               </div>
             </div>
-            <div class="body"></div>
-          </div>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -122,28 +128,33 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="container" id="container">
-            <div class="header" id="header">
-              <div class="headerLeft">
-                <gr-account-label deselected=""></gr-account-label>
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-account-label deselected=""></gr-account-label>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
               </div>
-              <div class="headerMiddle"></div>
-              <span class="patchset-text">Patchset 1</span>
-              <span class="separator"></span>
-              <span class="date" tabindex="0">
-                <gr-date-formatter withtooltip=""></gr-date-formatter>
-              </span>
-              <div class="show-hide" tabindex="0">
-                <label aria-label="Collapse" class="show-hide">
-                  <input class="show-hide" type="checkbox" />
-                  <gr-icon id="icon" icon="expand_less"></gr-icon>
-                </label>
+              <div class="body">
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
               </div>
             </div>
-            <div class="body">
-              <gr-formatted-text class="message"></gr-formatted-text>
-            </div>
-          </div>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -155,60 +166,65 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="container" id="container">
-            <div class="header" id="header">
-              <div class="headerLeft">
-                <span class="robotName">robot-id-123</span>
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <span class="robotName">robot-id-123</span>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
               </div>
-              <div class="headerMiddle"></div>
-              <span class="patchset-text">Patchset 1</span>
-              <span class="separator"></span>
-              <span class="date" tabindex="0">
-                <gr-date-formatter withtooltip=""></gr-date-formatter>
-              </span>
-              <div class="show-hide" tabindex="0">
-                <label aria-label="Collapse" class="show-hide">
-                  <input class="show-hide" type="checkbox" />
-                  <gr-icon id="icon" icon="expand_less"></gr-icon>
-                </label>
+              <div class="body">
+                <div class="robotId"></div>
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="robotActions">
+                  <gr-icon
+                    icon="link"
+                    class="copy link-icon"
+                    role="button"
+                    tabindex="0"
+                    title="Copy link to this comment"
+                  ></gr-icon>
+                  <gr-endpoint-decorator name="robot-comment-controls">
+                    <gr-endpoint-param name="comment"></gr-endpoint-param>
+                  </gr-endpoint-decorator>
+                  <gr-button
+                    aria-disabled="false"
+                    class="action show-fix"
+                    link=""
+                    role="button"
+                    secondary=""
+                    tabindex="0"
+                  >
+                    Show Fix
+                  </gr-button>
+                  <gr-button
+                    aria-disabled="false"
+                    class="action fix"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Please Fix
+                  </gr-button>
+                </div>
               </div>
             </div>
-            <div class="body">
-              <div class="robotId"></div>
-              <gr-formatted-text class="message"></gr-formatted-text>
-              <div class="robotActions">
-                <gr-icon
-                  icon="link"
-                  class="copy link-icon"
-                  role="button"
-                  tabindex="0"
-                  title="Copy link to this comment"
-                ></gr-icon>
-                <gr-endpoint-decorator name="robot-comment-controls">
-                  <gr-endpoint-param name="comment"></gr-endpoint-param>
-                </gr-endpoint-decorator>
-                <gr-button
-                  aria-disabled="false"
-                  class="action show-fix"
-                  link=""
-                  role="button"
-                  secondary=""
-                  tabindex="0"
-                >
-                  Show Fix
-                </gr-button>
-                <gr-button
-                  aria-disabled="false"
-                  class="action fix"
-                  link=""
-                  role="button"
-                  tabindex="0"
-                >
-                  Please Fix
-                </gr-button>
-              </div>
-            </div>
-          </div>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -242,64 +258,69 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="container draft" id="container">
-            <div class="header" id="header">
-              <div class="headerLeft">
-                <gr-tooltip-content
-                  class="draftTooltip"
-                  has-tooltip=""
-                  max-width="20em"
-                  title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
-                >
-                  <gr-icon filled icon="rate_review"></gr-icon>
-                  <span class="draftLabel">Draft</span>
-                </gr-tooltip-content>
-              </div>
-              <div class="headerMiddle"></div>
-              <span class="patchset-text">Patchset 1</span>
-              <span class="separator"></span>
-              <span class="date" tabindex="0">
-                <gr-date-formatter withtooltip=""></gr-date-formatter>
-              </span>
-              <div class="show-hide" tabindex="0">
-                <label aria-label="Collapse" class="show-hide">
-                  <input class="show-hide" type="checkbox" />
-                  <gr-icon id="icon" icon="expand_less"></gr-icon>
-                </label>
-              </div>
-            </div>
-            <div class="body">
-              <gr-formatted-text class="message"></gr-formatted-text>
-              <div class="actions">
-                <div class="action resolve">
-                  <label>
-                    <input checked="" id="resolvedCheckbox" type="checkbox" />
-                    Resolved
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container draft" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-tooltip-content
+                    class="draftTooltip"
+                    has-tooltip=""
+                    max-width="20em"
+                    title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+                  >
+                    <gr-icon filled icon="rate_review"></gr-icon>
+                    <span class="draftLabel">Draft</span>
+                  </gr-tooltip-content>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
                   </label>
                 </div>
-                <div class="rightActions">
-                  <gr-button
-                    aria-disabled="false"
-                    class="action discard"
-                    link=""
-                    role="button"
-                    tabindex="0"
-                  >
-                    Discard
-                  </gr-button>
-                  <gr-button
-                    aria-disabled="false"
-                    class="action edit"
-                    link=""
-                    role="button"
-                    tabindex="0"
-                  >
-                    Edit
-                  </gr-button>
+              </div>
+              <div class="body">
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="actions">
+                  <div class="action resolve">
+                    <label>
+                      <input checked="" id="resolvedCheckbox" type="checkbox" />
+                      Resolved
+                    </label>
+                  </div>
+                  <div class="rightActions">
+                    <gr-button
+                      aria-disabled="false"
+                      class="action discard"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Discard
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      class="action edit"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Edit
+                    </gr-button>
+                  </div>
                 </div>
               </div>
             </div>
-          </div>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -312,72 +333,77 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="container draft" id="container">
-            <div class="header" id="header">
-              <div class="headerLeft">
-                <gr-tooltip-content
-                  class="draftTooltip"
-                  has-tooltip=""
-                  max-width="20em"
-                  title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
-                >
-                  <gr-icon filled icon="rate_review"></gr-icon>
-                  <span class="draftLabel">Draft</span>
-                </gr-tooltip-content>
-              </div>
-              <div class="headerMiddle"></div>
-              <span class="patchset-text">Patchset 1</span>
-              <span class="separator"></span>
-              <span class="date" tabindex="0">
-                <gr-date-formatter withtooltip=""></gr-date-formatter>
-              </span>
-              <div class="show-hide" tabindex="0">
-                <label aria-label="Collapse" class="show-hide">
-                  <input class="show-hide" type="checkbox" />
-                  <gr-icon id="icon" icon="expand_less"></gr-icon>
-                </label>
-              </div>
-            </div>
-            <div class="body">
-              <gr-textarea
-                autocomplete="on"
-                class="code editMessage"
-                code=""
-                id="editTextarea"
-                rows="4"
-                text="This is the test comment message."
-              >
-              </gr-textarea>
-              <div class="actions">
-                <div class="action resolve">
-                  <label>
-                    <input checked="" id="resolvedCheckbox" type="checkbox" />
-                    Resolved
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container draft" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-tooltip-content
+                    class="draftTooltip"
+                    has-tooltip=""
+                    max-width="20em"
+                    title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+                  >
+                    <gr-icon filled icon="rate_review"></gr-icon>
+                    <span class="draftLabel">Draft</span>
+                  </gr-tooltip-content>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
                   </label>
                 </div>
-                <div class="rightActions">
-                  <gr-button
-                    aria-disabled="false"
-                    class="action cancel"
-                    link=""
-                    role="button"
-                    tabindex="0"
-                  >
-                    Cancel
-                  </gr-button>
-                  <gr-button
-                    aria-disabled="false"
-                    class="action save"
-                    link=""
-                    role="button"
-                    tabindex="0"
-                  >
-                    Save
-                  </gr-button>
+              </div>
+              <div class="body">
+                <gr-textarea
+                  autocomplete="on"
+                  class="code editMessage"
+                  code=""
+                  id="editTextarea"
+                  rows="4"
+                  text="This is the test comment message."
+                >
+                </gr-textarea>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="actions">
+                  <div class="action resolve">
+                    <label>
+                      <input checked="" id="resolvedCheckbox" type="checkbox" />
+                      Resolved
+                    </label>
+                  </div>
+                  <div class="rightActions">
+                    <gr-button
+                      aria-disabled="false"
+                      class="action cancel"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Cancel
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      class="action save"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Save
+                    </gr-button>
+                  </div>
                 </div>
               </div>
             </div>
-          </div>
+          </gr-endpoint-decorator>
         `
       );
     });
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 da90b17..6ab0ec4 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
@@ -154,7 +154,17 @@
     //    for this.
     // 4. Rewrite plain text ("text") to apply linking and other config-based
     //    rewrites. Text within code blocks is not passed here.
+    // 5. Open links in a new tab by rendering with target="_blank" attribute.
     function customRenderer(renderer: {[type: string]: Function}) {
+      renderer['link'] = (href: string, title: string, text: string) =>
+        /* HTML */
+        `<a
+          href="${href}"
+          target="_blank"
+          ${title ? `title="${title}"` : ''}
+          rel="noopener"
+          >${text}</a
+        >`;
       renderer['image'] = (href: string, _title: string, text: string) =>
         `![${text}](${href})`;
       renderer['codespan'] = (text: string) =>
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 b405e6b..6391347 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
@@ -332,7 +332,13 @@
             <div slot="markdown-html">
               <p>
                 @
-                <a href="mailto:someone@google.com"> someone@google.com </a>
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
               </p>
             </div>
           </marked-element>
@@ -383,7 +389,13 @@
             <div slot="markdown-html">
               <p>
                 <code>@</code>
-                <a href="mailto:someone@google.com"> someone@google.com </a>
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
               </p>
             </div>
           </marked-element>
@@ -401,7 +413,9 @@
           <marked-element>
             <div slot="markdown-html">
               <p>
-                <a href="https://www.google.com">myLink</a>
+                <a href="https://www.google.com" rel="noopener" target="_blank"
+                  >myLink</a
+                >
               </p>
             </div>
           </marked-element>
@@ -482,7 +496,9 @@
                 <p>block quote ${escapedDiv}</p>
               </blockquote>
               <p>
-                <a href="http://google.com">inline link ${escapedDiv}</a>
+                <a href="http://google.com" rel="noopener" target="_blank"
+                  >inline link ${escapedDiv}</a
+                >
               </p>
             </div>
           </marked-element>
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index 1d3802a..f706712 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -22,8 +22,11 @@
   ReviewInput,
   ReviewerInput,
   AttentionSetInput,
+  RelatedChangeAndCommitInfo,
 } from '../../types/common';
 import {getUserId} from '../../utils/account-util';
+import {getChangeNumber} from '../../utils/change-util';
+import {deepEqual} from '../../utils/deep-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -136,12 +139,12 @@
         return Promise.resolve(new Response());
       }
       return this.restApiService.executeChangeAction(
-        change._number,
+        getChangeNumber(change),
         change.actions!.abandon!.method,
         '/abandon',
         undefined,
         {message: reason ?? ''},
-        () => errFn && errFn(change._number)
+        () => errFn && errFn(getChangeNumber(change))
       );
     });
   }
@@ -152,7 +155,7 @@
       const change = current.allChanges.get(changeNum)!;
       if (!change) throw new Error('invalid change id');
       return this.restApiService.saveChangeReview(
-        change._number,
+        getChangeNumber(change),
         'current',
         reviewInput,
         () => {
@@ -196,7 +199,7 @@
         add_to_attention_set: attentionSetUpdates,
       };
       return this.restApiService.saveChangeReview(
-        change._number,
+        getChangeNumber(change),
         'current',
         reviewInput
       );
@@ -233,13 +236,13 @@
     );
   }
 
-  async sync(changes: ChangeInfo[]) {
-    const basicChanges = new Map(changes.map(c => [c._number, c]));
+  async sync(changes: (ChangeInfo | RelatedChangeAndCommitInfo)[]) {
+    const basicChanges = new Map(changes.map(c => [getChangeNumber(c), c]));
     let currentState = this.getState();
     const selectedChangeNums = currentState.selectedChangeNums.filter(
       changeNum => basicChanges.has(changeNum)
     );
-    const selectableChangeNums = changes.map(c => c._number);
+    const selectableChangeNums = changes.map(c => getChangeNumber(c));
     this.updateState({
       loadingState: LoadingState.LOADING,
       selectedChangeNums,
@@ -252,11 +255,13 @@
     }
     const changeDetails =
       await this.restApiService.getDetailedChangesWithActions(
-        changes.map(c => c._number)
+        changes.map(c => getChangeNumber(c))
       );
     currentState = this.getState();
     // Return early if sync has been called again since starting the load.
-    if (selectableChangeNums !== currentState.selectableChangeNums) return;
+    if (!deepEqual(selectableChangeNums, currentState.selectableChangeNums)) {
+      return;
+    }
     const allDetailedChanges: Map<NumericChangeId, ChangeInfo> = new Map();
     for (const detailedChange of changeDetails ?? []) {
       allDetailedChanges.set(detailedChange._number, detailedChange);
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 2192246..e2f75f7 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -500,7 +500,7 @@
     const getChangesStub = stubRestApi(
       'getDetailedChangesWithActions'
     ).callsFake(() => promise);
-    bulkActionsModel.sync([c1, c2]);
+    bulkActionsModel.sync([c1]);
     assert.strictEqual(getChangesStub.callCount, 1);
     await waitUntilObserved(
       bulkActionsModel.loadingState$,
@@ -534,7 +534,6 @@
     // Resolve the old promise.
     responsePromise1.resolve([
       {...createChange(), _number: 1, subject: 'Subject 1-old'},
-      {...createChange(), _number: 2, subject: 'Subject 2-old'},
     ] as ChangeInfo[]);
     await waitEventLoop();
     const model2 = bulkActionsModel.getState();
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 8e058aa..2b92529 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -62,6 +62,7 @@
   ChecksUpdate,
   PluginsModel,
 } from '../plugins/plugins-model';
+import {ChangeViewModel} from '../views/change';
 
 /**
  * The checks model maintains the state of checks for two patchsets: the latest
@@ -137,20 +138,6 @@
 }
 
 interface ChecksState {
-  /**
-   * This is the patchset number selected by the user. The *latest* patchset
-   * can be picked up from the change model.
-   */
-  patchsetNumberSelected?: PatchSetNumber;
-  /**
-   * This is the attempt number selected by the user. If this is `undefined`
-   * (default), then for each run the latest attempt is displayed.
-   */
-  attemptNumberSelected: AttemptChoice;
-  /**
-   * Current filter set by the user in the runs panel or via URL.
-   */
-  runFilterRegexp: string;
   /** Checks data for the latest patchset. */
   pluginStateLatest: {
     [name: string]: ChecksProviderState;
@@ -210,24 +197,30 @@
 
   private subscriptions: Subscription[] = [];
 
+  // TODO: Maybe consider migrating usages to `changeViewModel` directly.
   public checksSelectedPatchsetNumber$ = select(
-    this.state$,
-    state => state.patchsetNumberSelected
+    this.changeViewModel.checksPatchset$,
+    ps => ps
   );
 
   public checksSelectedAttemptNumber$ = select(
-    this.state$,
-    state => state.attemptNumberSelected
+    this.changeViewModel.attempt$,
+    attempt => attempt ?? LATEST_ATTEMPT
   );
 
-  public runFilterRegexp$ = select(this.state$, state => state.runFilterRegexp);
+  public runFilterRegexp$ = select(
+    this.changeViewModel.filter$,
+    filter => filter ?? ''
+  );
 
   public checksLatest$ = select(this.state$, state => state.pluginStateLatest);
 
-  public checksSelected$ = select(this.state$, state =>
-    state.patchsetNumberSelected
-      ? state.pluginStateSelected
-      : state.pluginStateLatest
+  public checksSelected$ = select(
+    combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
+    ([state, ps]) => {
+      const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
+      return this.getPluginState(state, checksPs);
+    }
   );
 
   public aPluginHasRegistered$ = select(
@@ -379,14 +372,12 @@
 
   constructor(
     readonly routerModel: RouterModel,
+    readonly changeViewModel: ChangeViewModel,
     readonly changeModel: ChangeModel,
     readonly reporting: ReportingService,
     readonly pluginsModel: PluginsModel
   ) {
     super({
-      patchsetNumberSelected: undefined,
-      attemptNumberSelected: LATEST_ATTEMPT,
-      runFilterRegexp: '',
       pluginStateLatest: {},
       pluginStateSelected: {},
     });
@@ -405,19 +396,6 @@
       this.checkToPluginMap$.subscribe(map => {
         this.checkToPluginMap = map;
       }),
-      combineLatest([
-        this.routerModel.routerPatchNum$,
-        this.changeModel.latestPatchNum$,
-      ]).subscribe(([routerPs, latestPs]) => {
-        this.latestPatchNum = latestPs;
-        if (latestPs === undefined) {
-          this.setPatchset(undefined);
-        } else if (typeof routerPs === 'number') {
-          this.setPatchset(routerPs);
-        } else {
-          this.setPatchset(latestPs);
-        }
-      }),
       this.firstLoadCompleted$
         .pipe(
           filter(completed => !!completed),
@@ -655,20 +633,18 @@
     this.setState(nextState);
   }
 
-  updateStateSetPatchset(patchsetNumberSelected?: PatchSetNumber) {
-    this.updateState({patchsetNumberSelected});
+  updateStateSetPatchset(num?: PatchSetNumber) {
+    this.changeViewModel.updateState({
+      checksPatchset: num === this.latestPatchNum ? undefined : num,
+    });
   }
 
   updateStateSetAttempt(attemptNumberSelected: AttemptChoice) {
-    this.updateState({attemptNumberSelected});
+    this.changeViewModel.updateState({attempt: attemptNumberSelected});
   }
 
   updateStateSetRunFilter(runFilterRegexp: string) {
-    this.updateState({runFilterRegexp});
-  }
-
-  setPatchset(num?: PatchSetNumber) {
-    this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
+    this.changeViewModel.updateState({filter: runFilterRegexp});
   }
 
   reload(pluginName: string) {
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 88fbebc..83ed464 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -21,6 +21,7 @@
 import {changeModelToken} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
+import {changeViewModelToken} from '../views/change';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -63,6 +64,7 @@
   setup(() => {
     model = new ChecksModel(
       getAppContext().routerModel,
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       getAppContext().reportingService,
       getAppContext().pluginsModel
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 7cd8b2a..2e90a5e 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -5,6 +5,7 @@
  */
 import {BehaviorSubject, Observable} from 'rxjs';
 import {Finalizable} from '../services/registry';
+import {deepEqual} from '../utils/deep-util';
 
 /**
  * A Model stores a value <T> and controls changes to that value via `subject$`
@@ -35,6 +36,7 @@
   }
 
   setState(state: T) {
+    if (deepEqual(state, this.getState())) return;
     this.subject$.next(state);
   }
 
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index b8451a0..826ec66 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -9,9 +9,11 @@
   RevisionPatchSetNum,
   BasePatchSetNum,
   ChangeInfo,
+  PatchSetNumber,
 } from '../../api/rest-api';
 import {GerritView} from '../../services/router/router-model';
 import {UrlEncodedCommentId} from '../../types/common';
+import {select} from '../../utils/observable-util';
 import {
   encodeURL,
   getBaseUrl,
@@ -24,22 +26,34 @@
 
 export interface ChangeViewState extends ViewState {
   view: GerritView.CHANGE;
+
   changeNum: NumericChangeId;
   project: RepoName;
   edit?: boolean;
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
   commentId?: UrlEncodedCommentId;
-  forceReload?: boolean;
-  openReplyDialog?: boolean;
   tab?: string;
+
+  /** Checks related view state */
+
+  /** selected patchset for check runs (undefined=latest) */
+  checksPatchset?: PatchSetNumber;
   /** regular expression for filtering check runs */
   filter?: string;
-  /** selected attempt for selected check runs */
+  /** selected attempt for check runs (undefined=latest) */
   attempt?: AttemptChoice;
 
+  /** State properties that trigger one-time actions */
+
+  /** for scrolling a Change Log message into view in gr-change-view */
   messageHash?: string;
+  /** for logging where the user came from */
   usp?: string;
+  /** triggers all change related data to be reloaded */
+  forceReload?: boolean;
+  /** triggers opening the reply dialog */
+  openReplyDialog?: boolean;
 }
 
 /**
@@ -84,6 +98,15 @@
   }
   let suffix = `${range}`;
   const queries = [];
+  if (state.checksPatchset && state.checksPatchset > 0) {
+    queries.push(`checksPatchset=${state.checksPatchset}`);
+  }
+  if (state.attempt) {
+    if (state.attempt !== 'latest') queries.push(`attempt=${state.attempt}`);
+  }
+  if (state.filter) {
+    queries.push(`filter=${state.filter}`);
+  }
   if (state.forceReload) {
     queries.push('forceReload=true');
   }
@@ -117,7 +140,27 @@
   define<ChangeViewModel>('change-view-model');
 
 export class ChangeViewModel extends Model<ChangeViewState | undefined> {
+  public readonly tab$ = select(this.state$, state => state?.tab);
+
+  public readonly checksPatchset$ = select(
+    this.state$,
+    state => state?.checksPatchset
+  );
+
+  public readonly attempt$ = select(this.state$, state => state?.attempt);
+
+  public readonly filter$ = select(this.state$, state => state?.filter);
+
   constructor() {
     super(undefined);
+    this.state$.subscribe(s => {
+      if (s?.usp || s?.forceReload || s?.openReplyDialog) {
+        this.updateState({
+          usp: undefined,
+          forceReload: undefined,
+          openReplyDialog: undefined,
+        });
+      }
+    });
   }
 }
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 9f7a0c0..2e1b817 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -186,6 +186,7 @@
 
   const checksModel = new ChecksModel(
     appContext.routerModel,
+    changeViewModel,
     changeModel,
     appContext.reportingService,
     appContext.pluginsModel
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 0699554..99b9870 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -29,6 +29,7 @@
 }
 
 export interface RouterState {
+  // Note that this router model view must be updated before view model state.
   view?: GerritView;
   changeNum?: NumericChangeId;
   patchNum?: RevisionPatchSetNum;
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index e34773e..2cdd4a5 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -207,6 +207,7 @@
   const checksModelCreator = () =>
     new ChecksModel(
       appContext.routerModel,
+      resolver(changeViewModelToken),
       resolver(changeModelToken),
       appContext.reportingService,
       appContext.pluginsModel
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 23acff4..597bb6f 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -8,7 +8,6 @@
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
-import {AttemptChoice} from '../models/checks/checks-util';
 
 export enum EventType {
   BIND_VALUE_CHANGED = 'bind-value-changed',
@@ -231,10 +230,6 @@
 export interface ChecksTabState {
   statusOrCategory?: RunStatus | Category;
   checkName?: string;
-  /** regular expression for filtering runs */
-  filter?: string;
-  /** selected attempt for selected runs */
-  attempt?: AttemptChoice;
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index ad5d48a..07b63d6 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -137,6 +137,20 @@
 ) {
   return change?.status === ChangeStatus.ABANDONED;
 }
+/**
+ * Get the change number from either a ChangeInfo (such as those included in
+ * SubmittedTogetherInfo responses) or get the change number from a
+ * RelatedChangeAndCommitInfo (such as those included in a
+ * RelatedChangesInfo response).
+ */
+export function getChangeNumber(
+  change: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+): NumericChangeId {
+  if (isChangeInfo(change)) {
+    return change._number;
+  }
+  return change._change_number!;
+}
 
 export function changeStatuses(
   change: ChangeInfo,
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
index 7096423..78e78ed 100644
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -14,6 +14,7 @@
   (pageCallback: PageCallback): void;
   show(url: string): void;
   redirect(url: string): void;
+  replace(path: string, state: null, init: boolean, dispatch: boolean): void;
   base(url: string): void;
   start(): void;
   exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;