Merge "Add support for using diff3 for rebasing and cherry-pick with conflicts"
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 6c77109..af944cf 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -3,7 +3,8 @@
 
 [WARNING]
 Prolog rules are no longer supported in Gerrit. Existing usages of prolog rules
-can be modified or deleted, but uploading new "rules.pl" files are rejected.
+can be modified or deleted. Uploading new "rules.pl" files will result in
+a warning being emitted.
 Please use link:config-submit-requirements.html[submit requirements] instead.
 Note that the link:#SubmitType[Submit Type] being deprecated in this
 documentation page currently has no substitution in submit requirements.
diff --git a/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
index 0911550..7a7c35b 100644
--- a/java/com/google/gerrit/server/account/HashedPassword.java
+++ b/java/com/google/gerrit/server/account/HashedPassword.java
@@ -136,6 +136,6 @@
 
   public boolean checkPassword(String password) {
     // Constant-time comparison, because we're paranoid.
-    return Arrays.areEqual(hashPassword(password, salt, cost, nullTerminate), hashed);
+    return Arrays.constantTimeAreEqual(hashPassword(password, salt, cost, nullTerminate), hashed);
   }
 }
diff --git a/java/com/google/gerrit/server/data/PatchSetAttribute.java b/java/com/google/gerrit/server/data/PatchSetAttribute.java
index dc47057..3f7c8e4 100644
--- a/java/com/google/gerrit/server/data/PatchSetAttribute.java
+++ b/java/com/google/gerrit/server/data/PatchSetAttribute.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.util.List;
 
-public class PatchSetAttribute {
+public class PatchSetAttribute implements Cloneable {
   public int number;
   public String revision;
   public List<String> parents;
@@ -32,4 +32,12 @@
   public List<PatchAttribute> files;
   public int sizeInsertions;
   public int sizeDeletions;
+
+  public PatchSetAttribute shallowClone() {
+    try {
+      return (PatchSetAttribute) super.clone();
+    } catch (CloneNotSupportedException e) {
+      throw new AssertionError(e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 2827f59..ac69120 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -76,6 +76,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -358,33 +359,23 @@
 
   public void addPatchSets(
       RevWalk revWalk,
+      Config repoConfig,
       ChangeAttribute ca,
-      Collection<PatchSet> ps,
-      Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      LabelTypes labelTypes,
-      AccountAttributeLoader accountLoader) {
-    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes, accountLoader);
-  }
-
-  public void addPatchSets(
-      RevWalk revWalk,
-      ChangeAttribute ca,
-      Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
       boolean includeFiles,
-      Change change,
-      LabelTypes labelTypes,
+      ChangeData changeData,
       AccountAttributeLoader accountLoader) {
-    if (!ps.isEmpty()) {
-      ca.patchSets = new ArrayList<>(ps.size());
-      for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p, accountLoader);
+    if (!changeData.patchSets().isEmpty()) {
+      ca.patchSets = new ArrayList<>(changeData.patchSets().size());
+      for (PatchSet p : changeData.patchSets()) {
+        PatchSetAttribute psa =
+            asPatchSetAttribute(revWalk, repoConfig, changeData, p, accountLoader);
         if (approvals != null) {
-          addApprovals(psa, p.id(), approvals, labelTypes, accountLoader);
+          addApprovals(psa, p.id(), approvals, changeData.getLabelTypes(), accountLoader);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
-          addPatchSetFileNames(psa, change, p);
+          addPatchSetFileNames(psa, changeData.change(), p);
         }
       }
     }
@@ -441,13 +432,18 @@
     }
   }
 
-  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
-    return asPatchSetAttribute(revWalk, change, patchSet, null);
+  public PatchSetAttribute asPatchSetAttribute(
+      RevWalk revWalk, Config repoConfig, ChangeData changeData, PatchSet patchSet) {
+    return asPatchSetAttribute(revWalk, repoConfig, changeData, patchSet, null);
   }
 
   /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
   public PatchSetAttribute asPatchSetAttribute(
-      RevWalk revWalk, Change change, PatchSet patchSet, AccountAttributeLoader accountLoader) {
+      RevWalk revWalk,
+      Config repoConfig,
+      ChangeData changeData,
+      PatchSet patchSet,
+      AccountAttributeLoader accountLoader) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.commitId().name();
     p.number = patchSet.number();
@@ -474,12 +470,12 @@
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+              changeData.project(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
       }
-      p.kind = changeKindCache.getChangeKind(change, patchSet);
+      p.kind = changeKindCache.getChangeKind(revWalk, repoConfig, changeData, patchSet);
     } catch (IOException | StorageException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
     } catch (DiffNotAvailableException e) {
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 89aebde..66e894c 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -149,6 +150,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final boolean enableDraftCommentEvents;
+  private final ChangeData.Factory changeDataFactory;
 
   private final String gerritInstanceId;
 
@@ -161,7 +163,8 @@
       PatchSetUtil psUtil,
       ChangeNotes.Factory changeNotesFactory,
       @GerritServerConfig Config config,
-      @Nullable @GerritInstanceId String gerritInstanceId) {
+      @Nullable @GerritInstanceId String gerritInstanceId,
+      ChangeData.Factory changeDataFactory) {
     this.dispatcher = dispatcher;
     this.eventFactory = eventFactory;
     this.projectCache = projectCache;
@@ -171,6 +174,7 @@
     this.enableDraftCommentEvents =
         config.getBoolean("event", "stream-events", "enableDraftCommentEvents", false);
     this.gerritInstanceId = gerritInstanceId;
+    this.changeDataFactory = changeDataFactory;
   }
 
   private ChangeNotes getNotes(ChangeInfo info) {
@@ -206,12 +210,13 @@
   }
 
   private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
-      final Change change, PatchSet patchSet) {
+      final ChangeData changeData, PatchSet patchSet) {
     return Suppliers.memoize(
         () -> {
-          try (Repository repo = repoManager.openRepository(change.getProject());
+          try (Repository repo = repoManager.openRepository(changeData.change().getProject());
               RevWalk revWalk = new RevWalk(repo)) {
-            return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
+            return eventFactory.asPatchSetAttribute(
+                revWalk, repo.getConfig(), changeData, patchSet);
           } catch (IOException e) {
             throw new RuntimeException(e);
           }
@@ -301,7 +306,7 @@
       PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), patchSet);
       event.uploader = accountAttributeSupplier(ev.getWho());
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -317,7 +322,8 @@
       Change change = notes.getChange();
       ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
       event.comment = ev.getComment();
@@ -338,7 +344,8 @@
       ReviewerAddedEvent event = new ReviewerAddedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.adder = accountAttributeSupplier(ev.getWho());
       for (AccountInfo reviewer : ev.getReviewers()) {
         event.reviewer = accountAttributeSupplier(reviewer);
@@ -466,7 +473,7 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.author = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), ps);
       event.comment = ev.getComment();
       event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
@@ -485,7 +492,8 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.restorer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.reason = ev.getReason();
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -503,7 +511,8 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.submitter = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.newRev = ev.getNewRevisionId();
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -521,7 +530,8 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.abandoner = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.reason = ev.getReason();
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -540,7 +550,7 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), patchSet);
 
       dispatcher.run(d -> d.postEvent(change, event));
     } catch (StorageException e) {
@@ -558,7 +568,7 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), patchSet);
 
       dispatcher.run(d -> d.postEvent(change, event));
     } catch (StorageException e) {
@@ -574,7 +584,8 @@
       VoteDeletedEvent event = new VoteDeletedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.comment = ev.getMessage();
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 7c8777f..c9b9f7a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -96,7 +96,12 @@
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
     ChangeData cd = changeDataFactory.create(project, ps.id().changeId());
-    return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
+    return revisionInfo(cd, ps);
+  }
+
+  public RevisionInfo revisionInfo(ChangeData changeData, PatchSet ps)
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    return revisionJsonFactory.create(changeOptions).getRevisionInfo(changeData, ps);
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index a60d982..f6d5881 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -78,7 +78,7 @@
       Event event =
           new Event(
               util.changeInfo(changeData),
-              util.revisionInfo(changeData.project(), patchSet),
+              util.revisionInfo(changeData, patchSet),
               util.accountInfo(uploader),
               when,
               notify.handling());
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index d3b5605..0fd9c0e 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -262,7 +261,6 @@
       Map<Project.NameKey, RevWalk> revWalks,
       AccountAttributeLoader accountLoader)
       throws IOException {
-    LabelTypes labelTypes = d.getLabelTypes();
     ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), accountLoader);
     c.hashtags = Lists.newArrayList(d.hashtags());
     eventFactory.extend(c, d.change());
@@ -286,67 +284,71 @@
       eventFactory.addCommitMessage(c, d.commitMessage());
     }
 
-    RevWalk rw = null;
     if (includePatchSets || includeCurrentPatchSet || includeDependencies) {
       Project.NameKey p = d.change().getProject();
-      rw = revWalks.get(p);
+      Repository repo;
+      RevWalk rw = revWalks.get(p);
       // Cache and reuse repos and revwalks.
       if (rw == null) {
-        Repository repo = repoManager.openRepository(p);
+        repo = repoManager.openRepository(p);
         checkState(repos.put(p, repo) == null);
         rw = new RevWalk(repo);
         revWalks.put(p, rw);
+      } else {
+        repo = repos.get(p);
       }
-    }
 
-    if (includePatchSets) {
-      eventFactory.addPatchSets(
-          rw,
-          c,
-          d.patchSets(),
-          includeApprovals ? d.conditionallyLoadApprovalsWithCopied().asMap() : null,
-          includeFiles,
-          d.change(),
-          labelTypes,
-          accountLoader);
-    }
-
-    if (includeCurrentPatchSet) {
-      PatchSet current = d.currentPatchSet();
-      if (current != null) {
-        c.currentPatchSet = eventFactory.asPatchSetAttribute(rw, d.change(), current);
-        eventFactory.addApprovals(
-            c.currentPatchSet, d.currentApprovals(), labelTypes, accountLoader);
-
-        if (includeFiles) {
-          eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
-        }
+      if (includePatchSets) {
+        eventFactory.addPatchSets(
+            rw,
+            repo.getConfig(),
+            c,
+            includeApprovals ? d.conditionallyLoadApprovalsWithCopied().asMap() : null,
+            includeFiles,
+            d,
+            accountLoader);
         if (includeComments) {
-          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments(), accountLoader);
+          for (PatchSetAttribute attribute : c.patchSets) {
+            eventFactory.addPatchSetComments(attribute, d.publishedComments(), accountLoader);
+          }
         }
       }
+
+      if (includeCurrentPatchSet) {
+        PatchSet current = d.currentPatchSet();
+        if (current != null) {
+          if (includePatchSets) {
+            for (PatchSetAttribute attribute : c.patchSets) {
+              if (attribute.number == current.number()) {
+                c.currentPatchSet = attribute.shallowClone();
+                // approvals will be populated later using different logic than --patch-sets uses
+                c.currentPatchSet.approvals = null;
+                break;
+              }
+            }
+          } else {
+            c.currentPatchSet =
+                eventFactory.asPatchSetAttribute(rw, repo.getConfig(), d, current, accountLoader);
+            if (includeFiles) {
+              eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
+            }
+            if (includeComments) {
+              eventFactory.addPatchSetComments(
+                  c.currentPatchSet, d.publishedComments(), accountLoader);
+            }
+          }
+          eventFactory.addApprovals(
+              c.currentPatchSet, d.currentApprovals(), d.getLabelTypes(), accountLoader);
+        }
+      }
+
+      if (includeDependencies) {
+        eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
+      }
     }
 
     if (includeComments) {
       eventFactory.addComments(c, d.messages(), accountLoader);
-      if (includePatchSets) {
-        eventFactory.addPatchSets(
-            rw,
-            c,
-            d.patchSets(),
-            includeApprovals ? d.approvals().asMap() : null,
-            includeFiles,
-            d.change(),
-            labelTypes,
-            accountLoader);
-        for (PatchSetAttribute attribute : c.patchSets) {
-          eventFactory.addPatchSetComments(attribute, d.publishedComments(), accountLoader);
-        }
-      }
-    }
-
-    if (includeDependencies) {
-      eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
     ImmutableList<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 36b859c..0a47d62 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.joining;
@@ -71,10 +72,10 @@
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -99,8 +100,6 @@
       "Submit all ${topicSize} changes of the same topic "
           + "(${submitSize} changes including ancestors and other "
           + "changes related by topic)";
-  private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
-      "This change depends on other hidden changes which are not ready";
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
 
@@ -240,48 +239,15 @@
    */
   @Nullable
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
-    try {
-      if (cs.furtherHiddenChanges()) {
-        logger.atFine().log(
-            "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
-            cd.getId().get(), user.getLoggableName(), cs.nonVisibleChanges());
-        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
-      }
-      for (ChangeData c : cs.changes()) {
-        Set<ChangePermission> can =
-            permissionBackend
-                .user(user)
-                .change(c)
-                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
-        if (!can.contains(ChangePermission.READ)) {
-          logger.atFine().log(
-              "Change %d cannot be submitted by user %s because it depends on change %d which the user cannot read",
-              cd.getId().get(), user.getLoggableName(), c.getId().get());
-          return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
-        }
-        if (!can.contains(ChangePermission.SUBMIT)) {
-          return "You don't have permission to submit change " + c.getId();
-        }
-        if (c.change().isWorkInProgress()) {
-          return "Change " + c.getId() + " is marked work in progress";
-        }
-        try {
-          // The data in the change index may be stale (e.g. if submit requirements have been
-          // changed). For that one change for which the submit action is computed, use the
-          // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
-          // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
-          // 'cs' only contains this one single change. If the ChangeSet contains further changes
-          // those may still be stale.
-          MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
-        } catch (ResourceConflictException e) {
-          return (c.getId() == cd.getId())
-              ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
-              : String.format(
-                  "Change %s must be submitted with change %s but %s is not ready: %s",
-                  cd.getId(), c.getId(), c.getId(), e.getMessage());
-        }
-      }
+    Optional<String> reason =
+        MergeOp.checkCommonSubmitProblems(cd.change(), cs, false, permissionBackend, user).stream()
+            .findFirst()
+            .map(MergeOp.ChangeProblem::getProblem);
+    if (reason.isPresent()) {
+      return reason.get();
+    }
 
+    try {
       if (!useMergeabilityCheck) {
         return null;
       }
@@ -298,7 +264,7 @@
         return "Problems with change(s): "
             + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
       }
-    } catch (PermissionBackendException | IOException e) {
+    } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error checking if change is submittable");
       throw new StorageException("Could not determine problems for the change", e);
     }
@@ -331,6 +297,15 @@
         mergeSuperSet
             .get()
             .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
+    // Replace potentially stale ChangeData for the current change with the fresher one.
+    cs =
+        new ChangeSet(
+            cs.changes().stream()
+                .map(csChange -> csChange.getId().equals(cd.getId()) ? cd : csChange)
+                .collect(toImmutableList()),
+            cs.nonVisibleChanges());
+    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
+
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -338,8 +313,6 @@
     }
     boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
 
-    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
-
     if (submitProblems != null) {
       return new UiAction.Description()
           .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
index b56d9ef..ed07f2f 100644
--- a/java/com/google/gerrit/server/submit/MergeMetrics.java
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -54,32 +54,36 @@
                 .setRate());
   }
 
-  public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
-    if (isRebaseOnBehalfOfUploader(cd)
-        && hasCodeReviewApprovalOfRealUploader(cd)
-        && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
-        && ignoresCodeReviewApprovalsOfUploader(cd)) {
-      // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
-      // The uploader of the patch set is the original uploader on whom's behalf the rebase was
-      // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
-      // clicking on the rebase button).
-      //
-      // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
-      //
-      // 3. The change doesn't have a Code-Review approval of any other user (a user that is not the
-      // real uploader).
-      //
-      // 4. Code-Review approvals of the uploader are ignored.
-      //
-      // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
-      // rebaser would have been the uploader of the patch set. In this case the Code-Review
-      // approval of the rebaser would not have counted since Code-Review approvals of the uploader
-      // are ignored.
-      //
-      // In this case we assume that the change would not be submittable if a normal rebase had been
-      // done. This is not always correct (e.g. if there are approvals of multiple reviewers) but
-      // it's good enough for the metric.
-      countChangesThatWereSubmittedWithRebaserApproval.increment();
+  public void countChangesThatWereSubmittedWithRebaserApproval(ChangeSet cs) {
+    for (ChangeData cd : cs.changes()) {
+      if (isRebaseOnBehalfOfUploader(cd)
+          && hasCodeReviewApprovalOfRealUploader(cd)
+          && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
+          && ignoresCodeReviewApprovalsOfUploader(cd)) {
+        // 1. The patch set that is being submitted was created by rebasing on behalf of the
+        // uploader.
+        //
+        // The uploader of the patch set is the original uploader on whose behalf the rebase was
+        // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g.
+        // by clicking on the rebase button).
+        //
+        // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
+        //
+        // 3. The change doesn't have a Code-Review approval of any other user (a user that is not
+        // the real uploader).
+        //
+        // 4. Code-Review approvals of the uploader are ignored.
+        //
+        // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
+        // rebaser would have been the uploader of the patch set. In this case the Code-Review
+        // approval of the rebaser would not have counted since Code-Review approvals of the
+        // uploader are ignored.
+        //
+        // In this case we assume that the change would not be submittable if a normal rebase had
+        // been done. This is not always correct (e.g. if there are approvals of multiple reviewers)
+        // but it's good enough for the metric.
+        countChangesThatWereSubmittedWithRebaserApproval.increment();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index eb41690..233f00e 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -69,6 +69,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -83,6 +84,8 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -112,6 +115,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Deque;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
@@ -292,6 +296,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
   private final MergeMetrics mergeMetrics;
+  private final PermissionBackend permissionBackend;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -336,7 +341,8 @@
       MergeMetrics mergeMetrics,
       ProjectCache projectCache,
       ExperimentFeatures experimentFeatures,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      PermissionBackend permissionBackend) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.batchUpdates = batchUpdates;
@@ -362,6 +368,7 @@
     hasImplicitMergeTimeoutSeconds =
         ConfigUtil.getTimeUnit(
             config, "change", null, "implicitMergeCalculationTimeout", 60, TimeUnit.SECONDS);
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -371,6 +378,12 @@
     }
   }
 
+  /**
+   * Check that SRs are fulfilled or throw otherwise
+   *
+   * @param cd change that is being checked
+   * @throws ResourceConflictException the exception that is thrown if the SR is not fulfilled
+   */
   public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
@@ -425,32 +438,119 @@
     return cd.submitRecords(submitRuleOptions(/* allowClosed= */ false));
   }
 
-  private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
-      throws ResourceConflictException {
-    checkArgument(
-        !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
+  @AutoValue
+  public abstract static class ChangeProblem {
+    public abstract Change.Id getChangeId();
+
+    public abstract String getProblem();
+
+    public static ChangeProblem create(Change.Id changeId, String problem) {
+      return new AutoValue_MergeOp_ChangeProblem(changeId, problem);
+    }
+  }
+
+  /**
+   * Returns a list of messages describing what prevents the current change from being submitted.
+   *
+   * <p>The method checks all changes in the {@code cs} for their current status, submitability and
+   * permissions.
+   *
+   * @param triggeringChange Change for which merge/submit action was initiated
+   * @param cs Set of changes that the current change depends on
+   * @param allowMerged True if change being already merged is not a problem to be reported
+   * @param permissionBackend Interface for checking user ACLs
+   * @param caller The user who is triggering a merge
+   * @return List of problems preventing merge
+   */
+  public static ImmutableList<ChangeProblem> checkCommonSubmitProblems(
+      Change triggeringChange,
+      ChangeSet cs,
+      boolean allowMerged,
+      PermissionBackend permissionBackend,
+      CurrentUser caller) {
+    ImmutableList.Builder<ChangeProblem> problems = ImmutableList.builder();
+    if (cs.furtherHiddenChanges()) {
+      logger.atFine().log(
+          "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
+          triggeringChange.getId().get(), caller.getLoggableName(), cs.nonVisibleChanges());
+      problems.add(
+          ChangeProblem.create(
+              triggeringChange.getId(),
+              String.format(
+                  "Change %d depends on other hidden changes", triggeringChange.getId().get())));
+    }
     for (ChangeData cd : cs.changes()) {
       try {
-        if (!cd.change().isNew()) {
+        Set<ChangePermission> can =
+            permissionBackend
+                .user(caller)
+                .change(cd)
+                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
+        if (!can.contains(ChangePermission.READ)) {
+          // The READ permission should already be handled during generation of ChangeSet, however
+          // MergeSuperSetComputation is an interface and on API level doesn't guarantee that this
+          // have been verified for all changes. Additionally, this protects against potential
+          // issues due to staleness.
+          logger.atFine().log(
+              "Change %d cannot be submitted by user %s because it depends on change %d which the"
+                  + "user cannot read",
+              triggeringChange.getId().get(), caller.getLoggableName(), cd.getId().get());
+          problems.add(
+              ChangeProblem.create(
+                  cd.getId(),
+                  String.format(
+                      "Change %d depends on other hidden changes",
+                      triggeringChange.getId().get())));
+        } else if (!can.contains(ChangePermission.SUBMIT)) {
+          logger.atFine().log(
+              "Change %d cannot be submitted by user %s because it depends on change %d which the"
+                  + "user cannot submit",
+              triggeringChange.getId().get(), caller.getLoggableName(), cd.getId().get());
+          problems.add(
+              ChangeProblem.create(
+                  cd.getId(),
+                  String.format("Insufficient permission to submit change %d", cd.getId().get())));
+        } else if (!cd.change().isNew()) {
           if (!(cd.change().isMerged() && allowMerged)) {
-            commitStatus.problem(
-                cd.getId(), "Change " + cd.getId() + " is " + ChangeUtil.status(cd.change()));
+            problems.add(
+                ChangeProblem.create(
+                    cd.getId(),
+                    String.format(
+                        "Change %d is %s", cd.getId().get(), ChangeUtil.status(cd.change()))));
           }
         } else if (cd.change().isWorkInProgress()) {
-          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
+          problems.add(
+              ChangeProblem.create(
+                  cd.getId(),
+                  String.format("Change %d is marked work in progress", cd.getId().get())));
         } else {
           checkSubmitRequirements(cd);
-          mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cd);
         }
       } catch (ResourceConflictException e) {
-        commitStatus.problem(cd.getId(), e.getMessage());
-      } catch (StorageException e) {
+        // Exception is thrown means submit requirement is not fulfilled.
+        problems.add(
+            ChangeProblem.create(
+                cd.getId(),
+                triggeringChange.getId().equals(cd.getId())
+                    ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
+                    : String.format(
+                        "Change %s must be submitted with change %s but %s is not ready: %s",
+                        triggeringChange.getId(), cd.getId(), cd.getId(), e.getMessage())));
+      } catch (StorageException | PermissionBackendException e) {
         String msg = "Error checking submit rules for change";
-        logger.atWarning().withCause(e).log("%s %s", msg, cd.getId());
-        commitStatus.problem(cd.getId(), msg);
+        logger.atWarning().withCause(e).log("%s %s", msg, triggeringChange.getId());
+        problems.add(ChangeProblem.create(cd.getId(), msg));
       }
     }
+    return problems.build();
+  }
+
+  private void checkSubmitRulesAndState(Change triggeringChange, ChangeSet cs, boolean allowMerged)
+      throws ResourceConflictException {
+    checkCommonSubmitProblems(triggeringChange, cs, allowMerged, permissionBackend, caller).stream()
+        .forEach(cp -> commitStatus.problem(cp.getChangeId(), cp.getProblem()));
     commitStatus.maybeFailVerbose();
+    mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cs);
   }
 
   private void bypassSubmitRulesAndRequirements(ChangeSet cs) {
@@ -580,7 +680,7 @@
                       this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
                       if (checkSubmitRules) {
                         logger.atFine().log("Checking submit rules and state");
-                        checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
+                        checkSubmitRulesAndState(change, filteredNoteDbChangeSet, isRetry);
                       } else {
                         logger.atFine().log("Bypassing submit rules");
                         bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
index fc8eaed..9af7a2d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
@@ -41,7 +41,8 @@
         .isEqualTo(
             String.format(
                 "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %s: submit requirement 'No-Unresolved-Comments' is unsatisfied.",
+                    + "Change %1$s: Change %1$s is not ready: "
+                    + "submit requirement 'No-Unresolved-Comments' is unsatisfied.",
                 r.getChange().getId().get()));
 
     // Resolve the comment and check that the change can be submitted now.
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 25af040..0b86406 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -589,7 +589,7 @@
             + num
             + ": Change "
             + num
-            + " is work in progress");
+            + " is marked work in progress");
   }
 
   @Test
@@ -607,7 +607,7 @@
               + num
               + ": Change "
               + num
-              + " is work in progress");
+              + " is marked work in progress");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1d8e0b8..5f1a982 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -95,12 +95,15 @@
     PushOneCommit.Result change2 = createChange();
 
     Change.Id id1 = change1.getPatchSetId().changeId();
+    Change.Id id2 = change2.getPatchSetId().changeId();
     submitWithConflict(
         change2.getChangeId(),
-        "Failed to submit 2 changes due to the following problems:\n"
-            + "Change "
-            + id1
-            + ": submit requirement 'Code-Review' is unsatisfied.");
+        String.format(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change %d"
+                + ": Change %d must be submitted with change %d but %d is not ready: "
+                + "submit requirement 'Code-Review' is unsatisfied.",
+            id1.get(), id2.get(), id1.get(), id1.get()));
 
     RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 5a4f073..4ee5967 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -16,7 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
@@ -25,6 +28,8 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -32,6 +37,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.EnumSet;
@@ -336,6 +342,41 @@
     assertSubmittedTogether(id2, id2, id1);
   }
 
+  @Test
+  public void permissionToSubmitForSomeChangesInTopic() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/heads/testbranch").group(REGISTERED_USERS))
+        .update();
+
+    createBranch(BranchNameKey.create(getProject(), "testbranch"));
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    // Create two independent commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/testbranch%topic=" + name("connectingTopic"), false);
+
+    approve(id1);
+    approve(id2);
+    if (isSubmitWholeTopicEnabled()) {
+      ResourceConflictException e =
+          assertThrows(ResourceConflictException.class, () -> submit(id1));
+      assertThat(e.getMessage())
+          .contains(
+              String.format(
+                  "Insufficient permission to submit change %d",
+                  gApi.changes().id(id2).get()._number));
+    } else {
+      submit(id1);
+    }
+  }
+
   private String getChangeId(RevCommit c) throws Exception {
     return GitUtil.getChangeId(testRepo, c).get();
   }
diff --git a/package.json b/package.json
index 82d3854..6aa807b 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
     "@typescript-eslint/parser": "^5.62.0"
   },
   "devDependencies": {
-    "@koa/cors": "^3.4.3",
+    "@koa/cors": "^5.0.0",
     "@types/page": "^1.11.9",
     "@typescript-eslint/eslint-plugin": "^5.62.0",
     "@web/dev-server": "^0.1.38",
diff --git a/plugins/hooks b/plugins/hooks
index 41c3ad1..4f43f5d 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 41c3ad1d584f3b81bacc59e89e022dc1e7ffc3f2
+Subproject commit 4f43f5db6b8aa7f36381f4f9a4c9ec1fc335d949
diff --git a/plugins/replication b/plugins/replication
index d1ad7d5..2e2a641 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit d1ad7d504171c8f68d2cf956bb3422fa84a8194f
+Subproject commit 2e2a6415a979e74e454a11e6e1637cb0d9d7a20a
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 8630f75..c595add 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -9,6 +9,7 @@
 import {ChangeReplyPluginApi} from './change-reply';
 import {ChecksPluginApi} from './checks';
 import {EventHelperPluginApi} from './event-helper';
+import {PluginElement} from './hook';
 import {PopupPluginApi} from './popup';
 import {ReportingPluginApi} from './reporting';
 import {ChangeActionsPluginApi} from './change-actions';
@@ -63,7 +64,7 @@
   suggestions(): SuggestionsPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
   getPluginName(): string;
-  hook<T extends HTMLElement>(
+  hook<T extends PluginElement>(
     endpointName: string,
     opt_options?: RegisterOptions
   ): HookApi<T>;
@@ -72,12 +73,12 @@
   popup(): Promise<PopupPluginApi>;
   popup(moduleName: string): Promise<PopupPluginApi>;
   popup(moduleName?: string): Promise<PopupPluginApi | null>;
-  registerCustomComponent<T extends HTMLElement>(
+  registerCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
-  registerDynamicCustomComponent<T extends HTMLElement>(
+  registerDynamicCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
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 c02d474..f1ef362 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
@@ -323,7 +323,6 @@
   @state()
   replyDisabled = true;
 
-  @state()
   private updateCheckTimerHandle?: number | null;
 
   @state() editMode = false;
diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs
index 2a7dca4..53a240e 100644
--- a/web-dev-server.config.mjs
+++ b/web-dev-server.config.mjs
@@ -1,5 +1,7 @@
 import { esbuildPlugin } from "@web/dev-server-esbuild";
 import cors from "@koa/cors";
+import path from 'node:path';
+import fs from 'node:fs';
 
 /** @type {import('@web/dev-server').DevServerConfig} */
 export default {
@@ -18,6 +20,20 @@
     // (ex: gerrit-review.googlesource.com), which happens during local
     // development with Gerrit FE Helper extension.
     cors({ origin: "*" }),
+    // Map some static assets.
+    // When creating the bundle, the files are moved by polygerrit_bundle() in
+    // polygerrit-ui/app/rules.bzl
+    async (context, next) => {
+
+      if ( context.url.includes("/bower_components/webcomponentsjs/webcomponents-lite.js") ) {
+        context.response.redirect("/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js");
+
+      } else if ( context.url.startsWith( "/fonts/" ) ) {
+        const fontFile = path.join( "lib/fonts", path.basename(context.url) );
+        context.body = fs.createReadStream( fontFile );
+      }
+      await next();
+    },
     // The issue solved here is that our production index.html does not load
     // 'gr-app.js' as an ESM module due to our build process, but in development
     // all our source code is written as ESM modules. When using the Gerrit FE
@@ -40,6 +56,7 @@
       await next();
 
       if (!isGrAppMjs && context.url.includes("gr-app.js")) {
+        context.set('Content-Type', 'application/javascript; charset=utf-8');
         context.body = "import('./gr-app.mjs')";
       }
     },
diff --git a/yarn.lock b/yarn.lock
index 9a3503c..7b49a65 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -252,10 +252,10 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
   integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
 
-"@koa/cors@^3.4.3":
-  version "3.4.3"
-  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.3.tgz#d669ee6e8d6e4f0ec4a7a7b0a17e7a3ed3752ebb"
-  integrity sha512-WPXQUaAeAMVaLTEFpoq3T2O1C+FstkjJnDQqy95Ck1UdILajsRhu6mhJ8H2f4NFPRBoCNN+qywTJfq/gGki5mw==
+"@koa/cors@^5.0.0":
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-5.0.0.tgz#0029b5f057fa0d0ae0e37dd2c89ece315a0daffd"
+  integrity sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==
   dependencies:
     vary "^1.1.2"