Merge "DeleteTag: remove unreachable check"
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index f5b7282..159e2fc 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -1007,17 +1007,9 @@
 Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
 are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
 
-==== ChangeAttributeFactory
-
-Alternatively, there is also `ChangeAttributeFactory` which takes in one single
-`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
-over this as it handles many changes at once which also decreases the round-trip
-time for queries resulting in performance increase for bulk queries.
-
-Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
-interfaces should check whether they need to contribute to the
-link:#change-etag-computation[change ETag computation] to prevent callers using
-ETags from potentially seeing outdated plugin attributes.
+Implementors of the `ChangePluginDefinedInfoFactory` interface should check whether
+they need to contribute to the link:#change-etag-computation[change ETag computation]
+to prevent callers using ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 50ebad7..a809eab 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1848,8 +1848,9 @@
 
 Gets whether the source is mergeable with the target branch.
 
-The `source` query parameter is required, which can be anything that could be
-resolved to a commit, see examples of the `source` attribute in
+The `source` query parameter is required, which can be anything that
+could be resolved to a commit, and is visible to the caller. See
+examples of the `source` attribute in
 link:rest-api-changes.html#merge-input[MergeInput].
 
 Also takes an optional parameter `strategy`, which can be `recursive`, `resolve`,
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index a91bc49..29dc6a3 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetChange;
@@ -40,7 +39,6 @@
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -86,21 +84,6 @@
     }
   }
 
-  protected static class NullAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
-    }
-  }
-
-  protected static class SimpleAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
   protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
     @Override
     public void configure() {
@@ -170,21 +153,6 @@
     private String opt;
   }
 
-  protected static class OptionAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance(
-              (cd, bp, p) -> {
-                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
-                return opts != null ? new MyInfo("opt " + opts.opt) : null;
-              });
-      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
-    }
-  }
-
   public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
     protected MyOptions opts;
 
@@ -211,33 +179,6 @@
     }
   }
 
-  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
-      assertThat(getter.call(id)).isNull();
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
-  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
-    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
-  }
-
-  protected void getChangeWithSimpleAttribute(
-      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
-      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
   protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
       throws Exception {
     Change.Id id = createChange().getChange().getId();
@@ -298,30 +239,6 @@
     assertThat(pluginInfos.get(changeWithInfo)).isNull();
   }
 
-  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-      BulkPluginInfoGetter getter) throws Exception {
-    Change.Id id1 = createChange().getChange().getId();
-    Change.Id id2 = createChange().getChange().getId();
-
-    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-
-    try (AutoCloseable ignored =
-            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
-        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
-      pluginInfos = getter.call();
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
-    }
-
-    pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-  }
-
   protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
       BulkPluginInfoGetter getter) throws Exception {
     Change.Id id1 = createChange().getChange().getId();
@@ -345,22 +262,6 @@
     assertThat(pluginInfos.get(id2)).isNull();
   }
 
-  protected void getChangeWithOption(
-      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
-      throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getterWithoutOptions.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
-      assertThat(getterWithoutOptions.call(id))
-          .containsExactly(new MyInfo("my-plugin", "opt null"));
-      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
-          .containsExactly(new MyInfo("my-plugin", "opt foo"));
-    }
-
-    assertThat(getterWithoutOptions.call(id)).isNull();
-  }
-
   protected void getChangeWithPluginDefinedBulkAttributeOption(
       BulkPluginInfoGetterWithId getterWithoutOptions,
       BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
@@ -455,11 +356,6 @@
   }
 
   @FunctionalInterface
-  protected interface PluginInfoGetter {
-    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
-  }
-
-  @FunctionalInterface
   protected interface BulkPluginInfoGetter {
     Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
   }
@@ -474,10 +370,4 @@
     Map<Change.Id, List<PluginDefinedInfo>> call(
         Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
-
-  @FunctionalInterface
-  protected interface PluginInfoGetterWithOptions {
-    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
-        throws Exception;
-  }
 }
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 3f04fa5..322c79e 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -140,6 +140,7 @@
     return true;
   }
 
+  /** The permission name, eg. {@code Permission.SUBMIT} */
   public abstract String getName();
 
   protected abstract boolean isExclusiveGroup();
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index e9c0136..894757b 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -39,7 +39,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -116,8 +115,6 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
-    bind(new TypeLiteral<DynamicSet<ChangeAttributeFactory>>() {})
-        .toInstance(DynamicSet.emptySet());
     bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
         .toInstance(DynamicMap.emptyMap());
     bind(String.class)
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index f861ea7..8c957ec 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -492,7 +492,7 @@
   }
 
   /**
-   * Resolves all accounts matching the input string.
+   * Resolves all accounts matching the input string, visible to the current user.
    *
    * <p>The following input formats are recognized:
    *
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 235537c..30021e6 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -61,6 +61,8 @@
  * <p>Other comment lines are ignored on read, and are not written back when the file is modified.
  */
 public class VersionedAuthorizedKeys extends VersionedMetaData {
+
+  /** Read/write SSH keys by user ID. */
   @Singleton
   public static class Accessor {
     private final GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
deleted file mode 100644
index 663d7aa..0000000
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.DynamicOptions.BeanProvider;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/**
- * Interface for plugins to provide additional fields in {@link
- * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
- *
- * <p>Register a {@code ChangeAttributeFactory} in a plugin {@code Module} like this:
- *
- * <pre>
- * DynamicSet.bind(binder(), ChangeAttributeFactory.class).to(YourClass.class);
- * </pre>
- *
- * <p>See the <a
- * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
- * developer documentation for more details and examples.
- */
-@Deprecated
-public interface ChangeAttributeFactory {
-
-  /**
-   * Create a plugin-provided info field.
-   *
-   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
-   *
-   * @param cd change.
-   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
-   * @param plugin plugin name.
-   * @return the plugin's special change info.
-   */
-  PluginDefinedInfo create(ChangeData cd, BeanProvider beanProvider, String plugin);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 6ab0c61..02da518 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -158,17 +158,12 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty(), Optional.empty());
+      return factory.create(options, Optional.empty());
     }
 
     public ChangeJson create(
-        Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
-        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
-      return factory.create(
-          options,
-          Optional.of(pluginDefinedAttributesFactory),
-          Optional.of(pluginDefinedInfosFactory));
+        Iterable<ListChangesOption> options, PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(options, Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -179,7 +174,6 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
         Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
@@ -226,7 +220,6 @@
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
   private final RevisionJson revisionJson;
-  private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
@@ -251,7 +244,6 @@
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
@@ -269,7 +261,6 @@
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
-    this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -611,17 +602,9 @@
     }
 
     setSubmitter(cd, out);
-    if (pluginDefinedAttributesFactory.isPresent()) {
-      out.plugins = pluginDefinedAttributesFactory.get().create(cd);
-    }
 
     if (!pluginInfos.isEmpty()) {
-      if (out.plugins == null) {
-        out.plugins = pluginInfos;
-      } else {
-        out.plugins = new ArrayList<>(out.plugins);
-        out.plugins.addAll(pluginInfos);
-      }
+      out.plugins = pluginInfos;
     }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index b474dab..db21f11 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -14,55 +14,22 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Collection;
-import java.util.Objects;
 import java.util.stream.Stream;
 
-/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+/** Static helpers for use by {@link PluginDefinedInfosFactory} implementations. */
 public class PluginDefinedAttributesFactories {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @Nullable
-  public static ImmutableList<PluginDefinedInfo> createAll(
-      ChangeData cd,
-      BeanProvider beanProvider,
-      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
-    ImmutableList<PluginDefinedInfo> result =
-        attrFactories
-            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
-            .filter(Objects::nonNull)
-            .collect(toImmutableList());
-    return !result.isEmpty() ? result : null;
-  }
-
-  @Nullable
-  private static PluginDefinedInfo tryCreate(
-      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
-    PluginDefinedInfo pdi = null;
-    try {
-      pdi = attrFactory.create(cd, beanProvider, plugin);
-    } catch (RuntimeException ex) {
-      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
-          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
-    }
-    if (pdi != null) {
-      pdi.name = plugin;
-    }
-    return pdi;
-  }
-
   public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
       Collection<ChangeData> cds,
       BeanProvider beanProvider,
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
deleted file mode 100644
index 08d6ce7..0000000
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
-
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
-}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index ee37d17..9308662 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -106,7 +106,6 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
@@ -441,7 +440,6 @@
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
-    DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
     DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 07c9e84..d4f22e6 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.CapabilityScope;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -31,7 +32,12 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** Global server permissions built into Gerrit. */
+/**
+ * Global server permissions built into Gerrit.
+ *
+ * <p>See also {@link GlobalCapability} which lists the equivalent strings used in the
+ * refs/meta/config settings in All-Projects.
+ */
 public enum GlobalPermission implements GlobalOrPluginPermission {
   ACCESS_DATABASE,
   ADMINISTRATE_SERVER,
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 89038e2..a1c48bc 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -385,6 +385,7 @@
     this.accountsSection = accountsSection;
   }
 
+  /** Returns an access section, {@code name} typically is a ref pattern. */
   public AccessSection getAccessSection(String name) {
     return accessSections.get(name);
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 370bc75..ed1f2f1 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
@@ -33,10 +32,8 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
-import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
@@ -60,7 +57,6 @@
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
     implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
-  private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
   private final List<Extension<ChangePluginDefinedInfoFactory>>
@@ -81,7 +77,6 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
-      DynamicSet<ChangeAttributeFactory> attributeFactories,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
       DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
@@ -95,14 +90,6 @@
     this.userProvider = userProvider;
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
 
-    ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
-        ImmutableListMultimap.builder();
-    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
-        ImmutableListMultimap.builder();
-    // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
-    // Provider on every call, which could be expensive if we invoke it once for every change.
-    attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
-    attributeFactoriesByPlugin = factoriesBuilder.build();
     changePluginDefinedInfoFactories
         .entries()
         .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
@@ -130,18 +117,6 @@
     return dynamicBeans.get(plugin);
   }
 
-  public PluginDefinedAttributesFactory getAttributesFactory() {
-    return this::buildPluginInfo;
-  }
-
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd,
-        this,
-        attributeFactoriesByPlugin.entries().stream()
-            .map(e -> new Extension<>(e.getKey(), e::getValue)));
-  }
-
   public PluginDefinedInfosFactory getInfosFactory() {
     return this::createPluginDefinedInfos;
   }
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index b931457..4922b57 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -330,15 +330,9 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    c.plugins = queryProcessor.getAttributesFactory().create(d);
     List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
     if (!pluginInfos.isEmpty()) {
-      if (c.plugins == null) {
-        c.plugins = pluginInfos;
-      } else {
-        c.plugins = new ArrayList<>(c.plugins);
-        c.plugins.addAll(pluginInfos);
-      }
+      c.plugins = pluginInfos;
     }
     return c;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 119e2e4..61ff6b8 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -53,6 +53,7 @@
       return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
+        // Must be authenticated to use 'me' or 'self'.
         throw new AuthException(e.getMessage(), e);
       }
       throw new ResourceNotFoundException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index 6df6c3c..9952987 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -60,8 +60,7 @@
 
   @Override
   public Response<List<SshKeyInfo>> apply(AccountResource rsrc)
-      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
index a80ab3f..3b431db 100644
--- a/java/com/google/gerrit/server/restapi/account/PutActive.java
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -32,7 +32,7 @@
  *
  * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/active} requests.
  *
- * <p>Only active accounts can login into Gerrit.
+ * <p>Only active accounts can login into Gerrit, or are suggested as reviewers.
  *
  * <p>Marking an account as inactive is handled by {@link DeleteActive}.
  */
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index c27bdd8..cc362f2 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -46,6 +46,12 @@
 import java.util.Set;
 import java.util.SortedSet;
 
+/**
+ * Implements adding label stars to changes.
+ *
+ * <p>This handles {@code POST} and {@code GET} for {@code
+ * /accounts/<account-identifier>/stars.changes/<change ID>}.
+ */
 @Singleton
 public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
 
@@ -70,6 +76,7 @@
   public Star parse(AccountResource parent, IdString id)
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
+    // This enforces visibility of the change.
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
     return new AccountResource.Star(user, change, labels);
@@ -87,6 +94,7 @@
 
   @Singleton
   public static class ListStarredChanges implements RestReadView<AccountResource> {
+
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
 
@@ -121,6 +129,7 @@
 
   @Singleton
   public static class Get implements RestReadView<AccountResource.Star> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -142,6 +151,7 @@
 
   @Singleton
   public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 1ef3c4b..f28d2b3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
@@ -27,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.ChangeResource;
@@ -46,7 +44,6 @@
         DynamicOptions.BeanReceiver,
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
-  private final DynamicSet<ChangeAttributeFactory> attrFactories;
   private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
@@ -62,12 +59,8 @@
   }
 
   @Inject
-  GetChange(
-      ChangeJson.Factory json,
-      DynamicSet<ChangeAttributeFactory> attrFactories,
-      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
+  GetChange(ChangeJson.Factory json, DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
     this.json = json;
-    this.attrFactories = attrFactories;
     this.pdiFactories = pdiFactories;
   }
 
@@ -91,12 +84,7 @@
   }
 
   private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
-  }
-
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd, this, Streams.stream(attrFactories.entries()));
+    return json.create(options, this::createPluginDefinedInfos);
   }
 
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 3c8157b..cf0d4cf 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -187,9 +187,7 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(
-                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
-            .format(results);
+        json.create(options, queryProcessor.getInfosFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 4ef724a..37616cd 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -25,7 +26,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.TraceContext;
@@ -37,15 +38,14 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
-@Singleton
-public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
+public class CheckAccess implements RestReadView<ProjectResource> {
   private final AccountResolver accountResolver;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitRepositoryManager;
@@ -60,7 +60,15 @@
     this.gitRepositoryManager = gitRepositoryManager;
   }
 
-  @Override
+  @Option(name = "--ref", usage = "ref name to check permission for")
+  String refName;
+
+  @Option(name = "--account", usage = "account to check acccess for")
+  String account;
+
+  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
+  String permission;
+
   public Response<AccessCheckInfo> apply(ProjectResource rsrc, AccessCheckInput input)
       throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
@@ -142,6 +150,22 @@
     info.status = statusCode;
     info.message = message;
     info.debugLogs = traceContext.getAclLogRecords();
+    if (info.debugLogs.isEmpty()) {
+      info.debugLogs =
+          ImmutableList.of("Found no rules that apply, so defaulting to no permission");
+    }
     return info;
   }
+
+  @Override
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
+      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
+
+    AccessCheckInput input = new AccessCheckInput();
+    input.ref = refName;
+    input.account = account;
+    input.permission = permission;
+
+    return apply(rsrc, input);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
deleted file mode 100644
index 6aaa678..0000000
--- a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.project;
-
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Option;
-
-public class CheckAccessReadView implements RestReadView<ProjectResource> {
-  String refName;
-  String account;
-  String permission;
-
-  @Inject CheckAccess checkAccess;
-
-  @Option(name = "--ref", usage = "ref name to check permission for")
-  void addOption(String refName) {
-    this.refName = refName;
-  }
-
-  @Option(name = "--account", usage = "account to check acccess for")
-  void setAccount(String account) {
-    this.account = account;
-  }
-
-  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
-  void setPermission(String perm) {
-    this.permission = perm;
-  }
-
-  @Override
-  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
-      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
-
-    AccessCheckInput input = new AccessCheckInput();
-    input.ref = refName;
-    input.account = account;
-    input.permission = permission;
-
-    return checkAccess.apply(rsrc, input);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 5418876..8a2ab0c 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -90,7 +90,11 @@
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
 
-/** List projects visible to the calling user. */
+/**
+ * List projects visible to the calling user.
+ *
+ * <p>This is like {@link QueryProjects} but with a slightly different calling convention.
+ */
 public class ListProjects implements RestReadView<TopLevelResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index ee3914d..70fb6ea 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -57,7 +57,7 @@
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
+    get(PROJECT_KIND, "check.access").to(CheckAccess.class);
 
     post(PROJECT_KIND, "check").to(Check.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index e4f7df5..a9d818d 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -36,6 +36,7 @@
 import java.util.List;
 import org.kohsuke.args4j.Option;
 
+/** Implements the {@code GET /projects/?query=QUERY} endpoint. */
 public class QueryProjects implements RestReadView<TopLevelResource> {
   private final ProjectIndexCollection indexes;
   private final ProjectQueryBuilder queryBuilder;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index 31198d5..cebce0b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -16,9 +16,6 @@
 
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
-import com.google.inject.AbstractModule;
 import java.util.Arrays;
 import org.junit.Test;
 
@@ -27,30 +24,6 @@
   // No tests for /detail via the extension API, since the extension API doesn't have that method.
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
@@ -63,22 +36,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
-        (id, opts) ->
-            pluginInfoFromSingletonList(
-                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
-        (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
-  }
-
-  @Test
   public void queryChangeWithOptionBulkAttribute() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
@@ -108,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
@@ -124,23 +75,4 @@
     getChangeWithPluginDefinedBulkAttributeWithException(
         id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
   }
-
-  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(ChangeAttributeFactory.class)
-          .annotatedWith(Exports.named("simple"))
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
-  @Test
-  public void getChangeWithSimpleAttributeWithExplicitExport() throws Exception {
-    // For backwards compatibility with old plugins, allow modules to bind into the
-    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
-    // this test to prove that the mapping works.
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
-        SimpleAttributeWithExplicitExportModule.class);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 709facc..45a895a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -19,14 +19,17 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -36,9 +39,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -48,6 +53,7 @@
 public class CheckAccessIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private GroupOperations groupOperations;
+  @Inject private AllProjectsName allProjectsName;
 
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
@@ -391,4 +397,34 @@
     assertThat(info.status).isEqualTo(200);
     assertThat(info.message).contains("no branches");
   }
+
+  @Test
+  @Sandboxed
+  public void noRules() throws Exception {
+    normalProject = projectOperations.newProject().create();
+
+    for (AccessSection section :
+        projectOperations.project(allProjectsName).getProjectConfig().getAccessSections()) {
+      if (!section.getName().startsWith(Constants.R_REFS)) {
+        continue;
+      }
+      for (Permission permission : section.getPermissions()) {
+        projectOperations
+            .project(allProjectsName)
+            .forUpdate()
+            .remove(permissionKey(permission.getName()).ref(section.getName()).build())
+            .update();
+      }
+    }
+    AccessCheckInput input = new AccessCheckInput();
+    input.account = privilegedUser.email();
+    input.permission = Permission.READ;
+    input.ref = "refs/heads/main";
+
+    AccessCheckInfo info = gApi.projects().name(normalProject.get()).checkAccess(input);
+    assertThat(info.status).isEqualTo(403);
+
+    assertThat(info.debugLogs).isNotEmpty();
+    assertThat(info.debugLogs.get(0)).contains("Found no rules");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 17bf37e..a4ec40e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
@@ -35,41 +33,6 @@
   private static final Gson GSON = OutputFormat.JSON.newGson();
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
@@ -88,27 +51,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeDetailWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
-  }
-
-  @Test
   public void pluginDefinedQueryChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
@@ -142,12 +84,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
@@ -204,24 +140,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
-      throws Exception {
-    res.assertOK();
-    List<Map<String, Object>> changeInfos =
-        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
-    assertThat(changeInfos).hasSize(1);
-    return decodeRawPluginsList(GSON, changeInfos.get(0).get("plugins"));
-  }
-
-  @Nullable
-  private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
-    res.assertOK();
-    Map<String, Object> changeInfo =
-        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
-    return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
-  }
-
-  @Nullable
   private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
       throws Exception {
     res.assertOK();
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 38293f9..009e05d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.ssh;
 
-import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableListMultimap;
@@ -41,31 +40,12 @@
   private static final Gson GSON = OutputStreamQuery.GSON;
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
-  }
-
-  @Test
   public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
@@ -85,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
@@ -116,15 +90,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
-      throws Exception {
-    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
-  }
-
-  @Nullable
   private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
       throws Exception {
     List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 7357ab4..740c35a 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 7357ab473599d16ae33cc982bbd65472f08c2dd6
+Subproject commit 740c35ae36f44748b3c91e60ee7dcb2fb6e99549
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index febf18a..fdf2811 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -38,11 +38,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {appContext} from '../../../services/app-context';
-import {
-  fetchChangeUpdates,
-  patchNumEquals,
-  CURRENT,
-} from '../../../utils/patch-set-util';
+import {fetchChangeUpdates, CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
   isOwner,
@@ -1153,7 +1149,7 @@
 
   _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
     for (const rev of Object.values(change.revisions)) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         return rev;
       }
     }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index 0279b7e..f7bca82 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -258,7 +258,7 @@
           rev2: revObj,
         },
       };
-      assert.deepEqual(element._getRevision(change, '2'), revObj);
+      assert.deepEqual(element._getRevision(change, 2), revObj);
     });
 
     test('_actionComparator sort order', () => {
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 684891f..126d017 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
@@ -69,7 +69,6 @@
   fetchChangeUpdates,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {changeStatuses, changeStatusString} from '../../../utils/change-util';
@@ -1656,7 +1655,7 @@
     if (!this._change) throw new Error('missing required change property');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
@@ -1670,7 +1669,7 @@
     if (!this._change) throw new Error('missing required change property');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
       fireAlert(this, 'Left is already base.');
       return;
     }
@@ -1685,7 +1684,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
@@ -1704,7 +1703,7 @@
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
@@ -1724,8 +1723,8 @@
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (
-      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+      this._patchRange.patchNum === latestPatchNum &&
+      this._patchRange.basePatchNum === ParentPatchSetNum
     ) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
@@ -1967,7 +1966,7 @@
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
-          patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+          this._patchRange.patchNum === currentRevision._number
         ) {
           // CommitInfo.commit is optional, and may need patching.
           if (currentRevision.commit && !currentRevision.commit.commit) {
@@ -2567,7 +2566,7 @@
     }
 
     const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+    return patchRange.patchNum === EditPatchSetNum;
   }
 
   _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
@@ -2651,10 +2650,7 @@
       throw new Error('missing required _patchRange property');
     let patchNum;
     if (
-      !patchNumEquals(
-        this._patchRange.patchNum,
-        computeLatestPatchNum(this._allPatchSets)
-      )
+      !(this._patchRange.patchNum === computeLatestPatchNum(this._allPatchSets))
     ) {
       patchNum = this._patchRange.patchNum;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 1195ea6..c4526bb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -246,8 +246,10 @@
   _computeDisableCherryPick(
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
-    statuses: Statuses
+    statuses: Statuses,
+    branch?: BranchName
   ) {
+    if (!branch) return true;
     const duplicateProject =
       cherryPickType === CherryPickType.TOPIC && duplicateProjectChanges;
     if (duplicateProject) return true;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
index 16262c6..4de395c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -85,7 +85,7 @@
   <gr-dialog
     confirm-label="Cherry Pick"
     cancel-label="[[_computeCancelLabel(_statuses)]]"
-    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses)]]"
+    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses, branch)]]"
     on-confirm="_handleConfirmTap"
     on-cancel="_handleCancelTap"
   >
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index ae34b70..5564fcf 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -174,6 +174,9 @@
     test('submit button is blocked while cherry picks is running', done => {
       const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
           .confirm;
+      assert.isTrue(confirmButton.hasAttribute('disabled'));
+      element.branch = 'b';
+      flush();
       assert.isFalse(confirmButton.hasAttribute('disabled'));
       element.updateStatus(changes[0], {status: 'RUNNING'});
       flush(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 27d9756..5949513 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -20,7 +20,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-dialog_html';
-import {patchNumEquals} from '../../../utils/patch-set-util';
 import {changeBaseURL} from '../../../utils/change-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
@@ -72,7 +71,7 @@
     }
 
     for (const rev of Object.values(this.change.revisions || {})) {
-      if (patchNumEquals(rev._number, this.patchNum)) {
+      if (rev._number === this.patchNum) {
         const fetch = rev.fetch;
         if (fetch) {
           return Object.keys(fetch).sort();
@@ -113,7 +112,7 @@
     if (!change || !selectedScheme) return [];
     for (const rev of Object.values(change.revisions || {})) {
       if (
-        patchNumEquals(rev._number, patchNum) &&
+        rev._number === patchNum &&
         rev &&
         rev.fetch &&
         hasOwnProperty(rev.fetch, selectedScheme)
@@ -171,7 +170,7 @@
 
     let shortRev = '';
     for (const rev in change.revisions) {
-      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
+      if (change.revisions[rev]._number === patchNum) {
         shortRev = rev.substr(0, 7);
         break;
       }
@@ -185,7 +184,7 @@
       return false;
     }
     for (const rev of Object.values(change.revisions || {})) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
         return parentLength === 0 || parentLength > 1;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 3c99648..146b3e2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -35,7 +35,6 @@
 import {
   computeLatestPatchNum,
   getRevisionByPatchNum,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {property, computed, observe, customElement} from '@polymer/decorators';
@@ -321,8 +320,7 @@
   _handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
-      (patchNumEquals(basePatchNum, this.basePatchNum) &&
-        patchNumEquals(patchNum, this.patchNum)) ||
+      (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
       !this.change
     ) {
       return;
@@ -354,7 +352,7 @@
 
   _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
     const latestNum = computeLatestPatchNum(allPatchSets);
-    if (patchNumEquals(patchNum, latestNum)) {
+    if (patchNum === latestNum) {
       return '';
     }
     return 'patchInfoOldPatchSet';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index cee0262..b8670c0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -249,11 +249,11 @@
 
   test('class is applied to file list on old patch set', () => {
     const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-    assert.equal(element._computePatchInfoClass('1', allPatchSets),
+    assert.equal(element._computePatchInfoClass(1, allPatchSets),
         'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('2', allPatchSets),
+    assert.equal(element._computePatchInfoClass(2, allPatchSets),
         'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+    assert.equal(element._computePatchInfoClass(4, allPatchSets), '');
   });
 
   suite('editMode behavior', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index fb20f0c..bc61dad 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -46,6 +46,11 @@
 import {appContext} from '../../../services/app-context';
 import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  computePredecessor,
+} from '../../../utils/patch-set-util';
 
 const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
@@ -270,13 +275,20 @@
   _handleViewPatchsetDiff(e: Event) {
     if (!this.message || !this.change) return;
     const match = this.message.message.match(/Uploaded patch set (\d+)./);
-    if (!match || match.length < 1) return;
-    const patchNum = Number(match[1]);
-    if (isNaN(patchNum)) throw new Error('invalid patchnum in message');
+    let patchNum: PatchSetNum;
+    // Message is of the form "Commit Message was updated" or "Patchset X
+    // was rebased"
+    if (!match || match.length < 1) {
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+    } else {
+      if (isNaN(Number(match[1])))
+        throw new Error('invalid patchnum in message');
+      patchNum = Number(match[1]) as PatchSetNum;
+    }
     GerritNav.navigateToChange(
       this.change,
-      patchNum as PatchSetNum,
-      (patchNum === 1 ? 'PARENT' : patchNum - 1) as PatchSetNum
+      patchNum,
+      computePredecessor(patchNum)
     );
     // stop propagation to stop message expansion
     e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index 94507e6..facde01 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-message.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {createChange, createRevisions} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-message');
 
@@ -232,7 +233,7 @@
     suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
       let navStub;
       setup(() => {
-        element.change = {changeNum: 12345};
+        element.change = {...createChange(), revisions: createRevisions(4)};
         navStub = sinon.stub(GerritNav, 'navigateToChange');
       });
 
@@ -241,7 +242,7 @@
           message: 'Uploaded patch set 1.',
         };
         element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly({changeNum: 12345}, 1,
+        assert.isTrue(navStub.calledWithExactly(element.change, 1,
             'PARENT'));
       });
 
@@ -250,21 +251,21 @@
           message: 'Uploaded patch set 2.',
         };
         element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly({changeNum: 12345}, 2, 1));
+        assert.isTrue(navStub.calledWithExactly(element.change, 2, 1));
 
         element.message = {
           message: 'Uploaded patch set 200.',
         };
         element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly({changeNum: 12345}, 200, 199));
+        assert.isTrue(navStub.calledWithExactly(element.change, 200, 199));
       });
 
-      test('invalid patchset does not cause navigation', () => {
+      test('Commit message updated', () => {
         element.message = {
-          message: 'Uploaded patch set XYZ.',
+          message: 'Commit message updated.',
         };
         element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isFalse(navStub.called);
+        assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
       });
     });
 
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 fc6b5ba..e6c9f42 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
@@ -25,7 +25,7 @@
 import {htmlTemplate} from './gr-related-changes-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {ChangeStatus} from '../../../constants/constants';
-import {patchNumEquals} from '../../../utils/patch-set-util';
+
 import {changeIsOpen} from '../../../utils/change-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -410,7 +410,7 @@
       return [];
     }
     for (const rev in change.revisions) {
-      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
+      if (change.revisions[rev]._number === patchNum) {
         changeRevision = rev;
       }
     }
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 07045cd..2f3d03c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -46,10 +46,7 @@
   WeblinkType,
 } from '../gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
-import {
-  convertToPatchSetNum,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {convertToPatchSetNum} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
@@ -693,10 +690,7 @@
 
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
-    if (
-      params.patchNum &&
-      patchNumEquals(params.basePatchNum, params.patchNum)
-    ) {
+    if (params.patchNum && params.basePatchNum === params.patchNum) {
       needsRedirect = true;
       params.basePatchNum = null;
     } else if (!hasPatchNum) {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 9988116..5210616 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -18,7 +18,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {patchNumEquals, CURRENT} from '../../../utils/patch-set-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
@@ -30,6 +30,7 @@
   NumericChangeId,
   PathToCommentsInfoMap,
   FileInfo,
+  ParentPatchSetNum,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
@@ -235,9 +236,7 @@
       allComments = allComments.concat(drafts);
     }
     if (patchNum) {
-      allComments = allComments.filter(c =>
-        patchNumEquals(c.patch_set, patchNum)
-      );
+      allComments = allComments.filter(c => c.patch_set === patchNum);
     }
     return allComments.map(c => {
       return {...c};
@@ -284,7 +283,7 @@
   getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
     let comments = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => patchNumEquals(c.patch_set, patchNum));
+      comments = comments.filter(c => c.patch_set === patchNum);
     }
     return comments.map(c => {
       return {...c, __draft: true};
@@ -369,6 +368,10 @@
    * does not return the range of the ported thread and it becomes a file level
    * thread.
    *
+   * If a comment was created with Side=PARENT, then we only show this ported
+   * comment if Base is part of the patch range, always on the left side of
+   * the diff.
+   *
    * @return only the ported threads for the specified file and patch range
    */
   _getPortedCommentThreads(
@@ -407,15 +410,22 @@
         return false;
       }
 
-      // TODO(dhruvsri): Add handling for thread.commentSide = PARENT
-      if (thread.commentSide === CommentSide.PARENT) return false;
+      thread.diffSide = Side.RIGHT;
+      if (thread.commentSide === CommentSide.PARENT) {
+        // TODO(dhruvsri): Add handling for merge parents
+        if (
+          patchRange.basePatchNum !== ParentPatchSetNum ||
+          !!thread.mergeParentNum
+        )
+          return false;
+        thread.diffSide = Side.LEFT;
+      }
 
       if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
 
       thread.range = portedComment.range;
       thread.line = portedComment.line;
       thread.ported = true;
-      thread.diffSide = Side.RIGHT;
       return true;
     });
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index dd36613..e107c16 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -20,6 +20,7 @@
 import {ChangeComments} from './gr-comment-api.js';
 import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
 import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
+import {CommentSide, Side} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
 
@@ -153,7 +154,7 @@
       const comment1 = {
         ...createComment(),
         unresolved: true,
-        id: 'db977012_e1f13818',
+        id: '1',
         line: 136,
         patch_set: 2,
         range: {
@@ -167,10 +168,22 @@
       const comment2 = {
         ...createComment(),
         patch_set: 2,
-        id: 'ecf0b9fa_fe1a5f62',
+        id: '2',
         line: 5,
       };
 
+      const comment3 = {
+        ...createComment(),
+        side: CommentSide.PARENT,
+        line: 10,
+        unresolved: true,
+      };
+
+      const comment4 = {
+        ...comment3,
+        parent: -2,
+      };
+
       const draft1 = {
         ...createDraft(),
         id: 'db977012_e1f13828',
@@ -277,6 +290,74 @@
         ).length, 0);
       });
 
+      test('comments with side=PARENT are ported over', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment left on Base
+              'karma.conf.js': [comment3],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            {/* ported comments */
+              'karma.conf.js': [{
+                ...comment3,
+                line: 31,
+                patch_set: 4,
+              }],
+            },
+            {}/* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+        assert.equal(portedThreads.length, 1);
+        assert.equal(portedThreads[0].line, 31);
+        assert.equal(portedThreads[0].diffSide, Side.LEFT);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
+        ).length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('comments left on merge parent is not ported over', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment left on Base
+              'karma.conf.js': [comment4],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            {/* ported comments */
+              'karma.conf.js': [{
+                ...comment4,
+                line: 31,
+                patch_set: 4,
+              }],
+            },
+            {}/* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+        assert.equal(portedThreads.length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
+        ).length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
+        ).length, 0);
+      });
+
       test('ported comments contribute to comment count', () => {
         assert.equal(changeComments.computeCommentsString(
             {basePatchNum: 'PARENT', patchNum: 2}, 'karma.conf.js',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 2c050f9..d09c811 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -1040,6 +1040,7 @@
         commentSide: CommentSide.REVISION,
         path: '/p',
         rootId: 'betsys_confession',
+        mergeParentNum: undefined,
         comments: [{
           id: 'betsys_confession',
           path: '/p',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 828c53d..84b227b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -44,7 +44,6 @@
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {
@@ -880,12 +879,10 @@
 
   _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
     if (!this._patchRange) return;
-    const leftPatchset = patchNumEquals(
-      this._patchRange.basePatchNum,
-      ParentPatchSetNum
-    )
-      ? 'Base'
-      : `Patchset ${this._patchRange.basePatchNum}`;
+    const leftPatchset =
+      this._patchRange.basePatchNum === ParentPatchSetNum
+        ? 'Base'
+        : `Patchset ${this._patchRange.basePatchNum}`;
     fireAlert(
       this,
       `${leftPatchset} vs
@@ -896,12 +893,12 @@
 
   _displayToasts() {
     if (!this._patchRange) return;
-    if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum !== ParentPatchSetNum) {
       this._displayDiffBaseAgainstLeftToast();
       return;
     }
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum !== latestPatchNum) {
       this._displayDiffAgainstLatestToast(latestPatchNum);
       return;
     }
@@ -916,17 +913,17 @@
       if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
       const revision = this._change.revisions[commitSha];
       const patchNum = revision._number;
-      if (patchNumEquals(patchNum, this._patchRange.patchNum)) {
+      if (patchNum === this._patchRange.patchNum) {
         commit = commitSha as CommitId;
         const commitObj = revision.commit;
         const parents = commitObj?.parents || [];
         if (
-          patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum) &&
+          this._patchRange.basePatchNum === ParentPatchSetNum &&
           parents.length
         ) {
           baseCommit = parents[parents.length - 1].commit;
         }
-      } else if (patchNumEquals(patchNum, this._patchRange.basePatchNum)) {
+      } else if (patchNum === this._patchRange.basePatchNum) {
         baseCommit = commitSha as CommitId;
       }
     }
@@ -1250,7 +1247,7 @@
     if (!patchRange) return {patchNum, basePatchNum};
     if (
       patchRange.basePatchNum !== ParentPatchSetNum ||
-      !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+      patchRange.patchNum !== latestPatchNum
     ) {
       patchNum = patchRange.patchNum;
       basePatchNum = patchRange.basePatchNum;
@@ -1352,8 +1349,8 @@
 
     const {basePatchNum, patchNum} = e.detail;
     if (
-      patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-      patchNumEquals(patchNum, this._patchRange.patchNum)
+      basePatchNum === this._patchRange.basePatchNum &&
+      patchNum === this._patchRange.patchNum
     ) {
       return;
     }
@@ -1582,7 +1579,7 @@
     patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
   ) {
     const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+    return patchRange.patchNum === EditPatchSetNum;
   }
 
   _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
@@ -1641,7 +1638,7 @@
     if (!this._path) return;
     if (!this._patchRange) return;
 
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
@@ -1658,7 +1655,7 @@
     if (!this._path) return;
     if (!this._patchRange) return;
 
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
       fireAlert(this, 'Left is already base.');
       return;
     }
@@ -1680,7 +1677,7 @@
     if (!this._patchRange) return;
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
@@ -1700,7 +1697,7 @@
     if (!this._patchRange) return;
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
@@ -1720,8 +1717,8 @@
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (
-      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+      this._patchRange.patchNum === latestPatchNum &&
+      this._patchRange.basePatchNum === ParentPatchSetNum
     ) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 04714bb..39d645e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -38,7 +38,7 @@
   rangesEqual,
 } from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
-import {isMergeParent, patchNumEquals} from '../../../utils/patch-set-util';
+import {isMergeParent} from '../../../utils/patch-set-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   BlameInfo,
@@ -605,10 +605,10 @@
       ? this.patchRange.basePatchNum
       : this.patchRange.patchNum;
 
-    const isEdit = patchNumEquals(patchNum, EditPatchSetNum);
+    const isEdit = patchNum === EditPatchSetNum;
     const isEditBase =
-      patchNumEquals(patchNum, ParentPatchSetNum) &&
-      patchNumEquals(this.patchRange.patchNum, EditPatchSetNum);
+      patchNum === ParentPatchSetNum &&
+      this.patchRange.patchNum === EditPatchSetNum;
 
     if (isEdit) {
       fireAlert(this, 'You cannot comment on an edit.');
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index ec39da2..671bd41 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -30,7 +30,6 @@
   getParentIndex,
   getRevisionByPatchNum,
   isMergeParent,
-  patchNumEquals,
   sortRevisions,
   PatchSet,
 } from '../../../utils/patch-set-util';
@@ -346,7 +345,7 @@
     patchNum: PatchSetNum,
     sortedRevisions: RevisionInfo[]
   ): boolean {
-    if (patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+    if (basePatchNum === ParentPatchSetNum) {
       return false;
     }
 
@@ -441,7 +440,7 @@
       });
       detail.patchNum = patchSetValue;
     } else {
-      if (patchNumEquals(detail.basePatchNum, patchSetValue)) return;
+      if (detail.basePatchNum === patchSetValue) return;
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
         current: e.detail.value,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index d9a3187..fbf7f47 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -264,10 +264,16 @@
           </gr-account-label>
         </template>
         <template is="dom-if" if="[[showPortedComment]]">
-          <a href="[[_getUrlForComment(comment)]]">
-            <span class="portedMessage">
-              Ported from patchset [[comment.patch_set]]
-            </span>
+          <a href="[[_getUrlForComment(comment)]]"
+            ><span class="portedMessage"
+              >Ported from patchset [[comment.patch_set]]</span
+            ></a
+          >
+          <a
+            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments"
+            target="_blank"
+          >
+            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
           </a>
         </template>
         <gr-tooltip-content
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
index f313174..c163924 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -145,7 +145,6 @@
       slot="dropdown-content"
       attr-for-selected="data-value"
       selected="{{value}}"
-      on-tap="_handleDropdownTap"
     >
       <template
         is="dom-repeat"
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index ae6e155..00f9a40 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -18,7 +18,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {getPluginLoader} from './gr-plugin-loader';
-import {patchNumEquals} from '../../../utils/patch-set-util';
+
 import {customElement} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -154,7 +154,7 @@
 
     let revision;
     for (const rev of Object.values(change.revisions || {})) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         revision = rev;
         break;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 078e07f..9e77784 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -36,11 +36,7 @@
 import {parseDate} from '../../../utils/date-util';
 import {getBaseUrl} from '../../../utils/url-util';
 import {appContext} from '../../../services/app-context';
-import {
-  getParentIndex,
-  isMergeParent,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
 import {
   ListChangesOption,
   listChangesOptionsToHex,
@@ -1354,7 +1350,7 @@
     let params = undefined;
     if (isMergeParent(patchRange.basePatchNum)) {
       params = {parent: getParentIndex(patchRange.basePatchNum)};
-    } else if (!patchNumEquals(patchRange.basePatchNum, ParentPatchSetNum)) {
+    } else if (patchRange.basePatchNum !== ParentPatchSetNum) {
       params = {base: patchRange.basePatchNum};
     }
     return this._getChangeURLAndFetch({
@@ -1401,7 +1397,7 @@
     changeNum: NumericChangeId,
     patchRange: PatchRange
   ): Promise<FileNameToFileInfoMap | undefined> {
-    if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
+    if (patchRange.patchNum === EditPatchSetNum) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
       );
@@ -1983,9 +1979,10 @@
       }
       return res;
     };
-    const promise = patchNumEquals(patchNum, EditPatchSetNum)
-      ? this._getFileInChangeEdit(changeNum, path)
-      : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+    const promise =
+      patchNum === EditPatchSetNum
+        ? this._getFileInChangeEdit(changeNum, path)
+        : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
     return promise.then(res => {
       if (!res || !res.ok) {
@@ -2277,8 +2274,7 @@
     };
     if (isMergeParent(basePatchNum)) {
       params.parent = getParentIndex(basePatchNum);
-    } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) {
-      // TODO (TS): fix as PatchSetNum in the condition above
+    } else if (basePatchNum !== ParentPatchSetNum) {
       params.base = basePatchNum;
     }
     const endpoint = `/files/${encodeURIComponent(path)}/diff`;
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index fadbfa7..d4bbe16 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {patchNumEquals} from '../../../utils/patch-set-util';
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
 import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
 
@@ -71,8 +70,8 @@
 
   getParentId(patchNum: PatchSetNum, parentIndex: number) {
     if (!this.change.revisions) return;
-    const rev = Object.values(this.change.revisions).find(rev =>
-      patchNumEquals(rev._number, patchNum)
+    const rev = Object.values(this.change.revisions).find(
+      rev => rev._number === patchNum
     );
     if (!rev || !rev.commit) return;
     return rev.commit.parents[parentIndex].commit;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5d8ea37..f4c0692 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -29,7 +29,7 @@
 import {parseDate} from './date-util';
 import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
 import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
-import {isMergeParent, getParentIndex, patchNumEquals} from './patch-set-util';
+import {isMergeParent, getParentIndex} from './patch-set-util';
 
 export interface DraftCommentProps {
   __draft?: boolean;
@@ -126,6 +126,7 @@
       comments: [comment],
       patchNum: comment.patch_set,
       commentSide: comment.side ?? CommentSide.REVISION,
+      mergeParentNum: comment.parent,
       path: comment.path,
       line: comment.line,
       range: comment.range,
@@ -151,6 +152,11 @@
   comments: UIComment[];
   path: string;
   commentSide: CommentSide;
+  /* mergeParentNum is the merge parent number only valid for merge commits
+     when commentSide is PARENT.
+     mergeParentNum is undefined for auto merge commits
+  */
+  mergeParentNum?: number;
   patchNum?: PatchSetNum;
   line?: LineNumber;
   /* rootId is optional since we create a empty comment thread element for
@@ -196,7 +202,7 @@
   if (
     range.basePatchNum === ParentPatchSetNum &&
     comment.side === CommentSide.PARENT &&
-    patchNumEquals(comment.patch_set, range.patchNum)
+    comment.patch_set === range.patchNum
   ) {
     return true;
   }
@@ -204,7 +210,7 @@
   return (
     range.basePatchNum !== ParentPatchSetNum &&
     comment.side !== CommentSide.PARENT &&
-    patchNumEquals(comment.patch_set, range.basePatchNum)
+    comment.patch_set === range.basePatchNum
   );
 }
 
@@ -217,8 +223,7 @@
   range: PatchRange
 ) {
   return (
-    comment.side !== CommentSide.PARENT &&
-    patchNumEquals(comment.patch_set, range.patchNum)
+    comment.side !== CommentSide.PARENT && comment.patch_set === range.patchNum
   );
 }
 
@@ -247,7 +252,7 @@
       patchNum: comment.patch_set,
       basePatchNum: ParentPatchSetNum,
     };
-  } else if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+  } else if (latestPatchNum === comment.patch_set) {
     return {
       patchNum: latestPatchNum,
       basePatchNum: ParentPatchSetNum,
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index fda302b..cb0bdec 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -60,19 +60,6 @@
 }
 
 /**
- * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
- * this function checks for patchNum equality.
- *
- */
-export function patchNumEquals(a?: PatchSetNum, b?: PatchSetNum) {
-  if (a === undefined) {
-    return a === b;
-  }
-  // TODO(TS): replace with a===b when the whole code is converted to ts
-  return `${a}` === `${b}`;
-}
-
-/**
  * Whether the given patch is a numbered parent of a merge (i.e. a negative
  * number).
  */
@@ -114,7 +101,7 @@
   patchNum: PatchSetNum
 ) {
   for (const rev of revisions) {
-    if (patchNumEquals(rev._number, patchNum)) {
+    if (rev._number === patchNum) {
       return rev;
     }
   }
@@ -275,6 +262,20 @@
   return allPatchSets[0].num;
 }
 
+export function computePredecessor(
+  patchset?: PatchSetNum
+): PatchSetNum | undefined {
+  if (
+    !patchset ||
+    patchset === ParentPatchSetNum ||
+    patchset === EditPatchSetNum
+  ) {
+    return undefined;
+  }
+  if (patchset === 1) return ParentPatchSetNum;
+  return (Number(patchset) - 1) as PatchSetNum;
+}
+
 export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
   if (!allPatchSets || allPatchSets.length < 2) {
     return false;
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
index 29cc370..12f5ed4 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ b/polygerrit-ui/app/utils/patch-set-util_test.js
@@ -21,7 +21,7 @@
   fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
   getParentIndex, getRevisionByPatchNum,
   isMergeParent,
-  patchNumEquals, sortRevisions,
+  sortRevisions,
 } from './patch-set-util.js';
 
 suite('gr-patch-set-util tests', () => {
@@ -31,9 +31,9 @@
       {_number: 1},
       {_number: 2},
     ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, '1'), revisions[1]);
+    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
     assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, '3'), undefined);
+    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
   });
 
   test('fetchChangeUpdates on latest', done => {
@@ -231,17 +231,6 @@
         .assertWip(6, true); // PS6 was uploaded with WIP option
   });
 
-  test('patchNumEquals', () => {
-    assert.isFalse(patchNumEquals('edit', 'PARENT'));
-    assert.isFalse(patchNumEquals('edit', NaN));
-    assert.isFalse(patchNumEquals(1, '2'));
-
-    assert.isTrue(patchNumEquals(1, '1'));
-    assert.isTrue(patchNumEquals(1, 1));
-    assert.isTrue(patchNumEquals('edit', 'edit'));
-    assert.isTrue(patchNumEquals('PARENT', 'PARENT'));
-  });
-
   test('isMergeParent', () => {
     assert.isFalse(isMergeParent(1));
     assert.isFalse(isMergeParent(4321));