Merge "Refactor SetAccess and CreateAccessChange."
diff --git a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
index bea6dd3..45a9ddf 100644
--- a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
+++ b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
@@ -16,13 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,11 +28,9 @@
  * Low-level storage API to load Gerrit's default config from {@code All-Users}. Should not be used
  * directly.
  */
-public class VersionedDefaultPreferences extends VersionedMetaData {
+public class VersionedDefaultPreferences extends VersionedConfigFile {
   private static final String PREFERENCES_CONFIG = "preferences.config";
 
-  private Config cfg;
-
   public static Config get(Repository allUsersRepo, AllUsersName allUsersName)
       throws StorageException, ConfigInvalidException {
     VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
@@ -46,27 +42,13 @@
     return versionedDefaultPreferences.getConfig();
   }
 
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_USERS_DEFAULT;
+  public VersionedDefaultPreferences() {
+    super(RefNames.REFS_USERS_DEFAULT, PREFERENCES_CONFIG, "Update default preferences\n");
   }
 
+  @Override
   public Config getConfig() {
     checkState(cfg != null, "Default preferences not loaded yet.");
     return cfg;
   }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(PREFERENCES_CONFIG);
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException {
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Update default preferences\n");
-    }
-    saveConfig(PREFERENCES_CONFIG, cfg);
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java b/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java
new file mode 100644
index 0000000..de8dabd
--- /dev/null
+++ b/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2024 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.git.meta;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.RefNames;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Versioned configuration file living in git
+ *
+ * <p>This class is a low-level API that allows callers to read the config directly from a
+ * repository and make updates to it.
+ */
+public class VersionedConfigFile extends VersionedMetaData {
+  protected final String ref;
+  protected final String fileName;
+  protected final String defaultOnSaveMessage;
+  protected Config cfg;
+
+  public VersionedConfigFile(String fileName) {
+    this(RefNames.REFS_CONFIG, fileName);
+  }
+
+  public VersionedConfigFile(String ref, String fileName) {
+    this(ref, fileName, "Updated configuration\n");
+  }
+
+  public VersionedConfigFile(String ref, String fileName, String defaultOnSaveMessage) {
+    this.ref = ref;
+    this.fileName = fileName;
+    this.defaultOnSaveMessage = defaultOnSaveMessage;
+  }
+
+  public Config getConfig() {
+    if (cfg == null) {
+      cfg = new Config();
+    }
+    return cfg;
+  }
+
+  protected String getFileName() {
+    return fileName;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    cfg = readConfig(fileName);
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage(defaultOnSaveMessage);
+    }
+    saveConfig(fileName, cfg);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
index 33e0a87..c4bc9fb 100644
--- a/java/com/google/gerrit/server/project/LabelConfigValidator.java
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -352,7 +353,7 @@
 
   private Config loadNewConfig(CommitReceivedEvent receiveEvent)
       throws IOException, ConfigInvalidException {
-    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
     return bareConfig.getConfig();
   }
@@ -364,8 +365,7 @@
     }
 
     try {
-      ProjectLevelConfig.Bare bareConfig =
-          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
       bareConfig.load(
           receiveEvent.project.getNameKey(),
           receiveEvent.revWalk,
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 8256198..7ab7629 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -18,60 +18,15 @@
 
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.ImmutableConfig;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
 /** Configuration file in the projects refs/meta/config branch. */
 public class ProjectLevelConfig {
-  /**
-   * This class is a low-level API that allows callers to read the config directly from a repository
-   * and make updates to it.
-   */
-  public static class Bare extends VersionedMetaData {
-    private final String fileName;
-    @Nullable private Config cfg;
-
-    public Bare(String fileName) {
-      this.fileName = fileName;
-      this.cfg = null;
-    }
-
-    public Config getConfig() {
-      if (cfg == null) {
-        cfg = new Config();
-      }
-      return cfg;
-    }
-
-    @Override
-    protected String getRefName() {
-      return RefNames.REFS_CONFIG;
-    }
-
-    @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
-      cfg = readConfig(fileName);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException {
-      if (commit.getMessage() == null || "".equals(commit.getMessage())) {
-        commit.setMessage("Updated configuration\n");
-      }
-      saveConfig(fileName, cfg);
-      return true;
-    }
-  }
-
   private final String fileName;
   private final ProjectState project;
   private final ImmutableConfig immutableConfig;
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
index 46a6857..927d3fd1 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -127,8 +127,7 @@
    *     parsed
    */
   public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
-    ProjectLevelConfig.Bare projectConfig =
-        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     try (Repository repo = repoManager.openRepository(projectName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
       boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 2ca79342..d8da13d 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -130,8 +130,7 @@
       ui.message(String.format("Skipping project %s because it has prolog rules", project));
       return Status.HAS_PROLOG;
     }
-    ProjectLevelConfig.Bare projectConfig =
-        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     boolean migrationPerformed = false;
     try (Repository repo = repoManager.openRepository(project);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
@@ -275,7 +274,7 @@
     cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
   }
 
-  private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+  private void commit(VersionedConfigFile projectConfig, MetaDataUpdate md) throws IOException {
     md.getCommitBuilder().setAuthor(serverUser);
     md.getCommitBuilder().setCommitter(serverUser);
     md.setMessage(COMMIT_MSG);
diff --git a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
index 468c26b..fe28d6f 100644
--- a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
+++ b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
@@ -14,17 +14,12 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 
 /** Preferences for user accounts during schema migrations. */
-class VersionedAccountPreferences extends VersionedMetaData {
+class VersionedAccountPreferences extends VersionedConfigFile {
   static final String PREFERENCES = "preferences.config";
 
   static VersionedAccountPreferences forUser(Account.Id id) {
@@ -35,33 +30,7 @@
     return new VersionedAccountPreferences(RefNames.REFS_USERS_DEFAULT);
   }
 
-  private final String ref;
-  private Config cfg;
-
   protected VersionedAccountPreferences(String ref) {
-    this.ref = ref;
-  }
-
-  @Override
-  protected String getRefName() {
-    return ref;
-  }
-
-  Config getConfig() {
-    return cfg;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(PREFERENCES);
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Updated preferences\n");
-    }
-    saveConfig(PREFERENCES, cfg);
-    return true;
+    super(ref, PREFERENCES, "Updated preferences\n");
   }
 }
diff --git a/java/com/google/gerrit/truth/OptionalSubject.java b/java/com/google/gerrit/truth/OptionalSubject.java
index 545932b..2023765 100644
--- a/java/com/google/gerrit/truth/OptionalSubject.java
+++ b/java/com/google/gerrit/truth/OptionalSubject.java
@@ -34,17 +34,7 @@
     return assertAbout(optionals()).thatCustom(optional, valueSubjectFactory);
   }
 
-  /*
-   * This could be made to return OptionalSubject<Subject, T>. But that wouldn't help users, since T
-   * is not used in the API. And it could result in a longer type ("OptionalSubject<Subject,
-   * SomeVeryLongType<Foo, Bar>>") in some cases.
-   *
-   * The parameter type "Optional<T>" remains important, however: It causes javac to prefer this
-   * assertThat method over Truth.assertThat(Optional) when we static import assertThat from both
-   * this class and Truth.
-   */
-  @SuppressWarnings("AlmostJavadoc") // "<T>" looks like an HTML tag?
-  public static <T> OptionalSubject<Subject, ?> assertThat(Optional<T> optional) {
+  public static OptionalSubject<Subject, ?> assertThat(Optional<?> optional) {
     return assertAbout(optionals()).that(optional);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 5bdf91f..6204115 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.gerrit.truth.OptionalSubject;
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -1629,7 +1630,7 @@
 
     TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
 
-    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+    OptionalSubject.assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
   }
 
   @Test
@@ -1640,7 +1641,7 @@
 
     TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
 
-    assertThat(comment.tag()).value().isEqualTo("tag");
+    OptionalSubject.assertThat(comment.tag()).value().isEqualTo("tag");
   }
 
   @Test
@@ -1700,7 +1701,7 @@
     TestHumanComment comment =
         changeOperations.change(changeId).draftComment(childCommentUuid).get();
 
-    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+    OptionalSubject.assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
   }
 
   @Test
@@ -1730,7 +1731,7 @@
     TestRobotComment comment =
         changeOperations.change(changeId).robotComment(childCommentUuid).get();
 
-    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+    OptionalSubject.assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
   }
 
   private ChangeInfo getChangeFromServer(Change.Id changeId) throws RestApiException {
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
index 333c229..7ed2391 100644
--- a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -52,7 +53,7 @@
 
   @Test
   public void parseNoRef() throws Exception {
-    assertThat(IntBlob.parse(repo, "refs/nothing")).isEmpty();
+    OptionalSubject.assertThat(IntBlob.parse(repo, "refs/nothing")).isEmpty();
   }
 
   @Test
@@ -66,14 +67,18 @@
   public void parseValid() throws Exception {
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob("123"));
-    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(id, 123));
   }
 
   @Test
   public void parseWithWhitespace() throws Exception {
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob(" 123 "));
-    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(id, 123));
   }
 
   @Test
@@ -92,7 +97,7 @@
         IntBlob.tryStore(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
   }
@@ -105,7 +110,7 @@
             repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
   }
@@ -118,7 +123,7 @@
         IntBlob.tryStore(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.FORCED);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(ru.getNewObjectId(), 456));
   }
@@ -137,14 +142,14 @@
             GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.LOCK_FAILURE);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName)).isEmpty();
   }
 
   @Test
   public void storeNoOldId() throws Exception {
     String refName = "refs/foo";
     IntBlob.store(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(getRef(refName), 123));
   }
@@ -154,7 +159,7 @@
     String refName = "refs/foo";
     IntBlob.store(
         repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(getRef(refName), 123));
   }
@@ -164,7 +169,7 @@
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob("123"));
     IntBlob.store(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(getRef(refName), 456));
   }
@@ -185,7 +190,7 @@
                     123,
                     GitReferenceUpdated.DISABLED));
     assertThat(thrown.getFailedRefs()).containsExactly("refs/foo");
-    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName)).isEmpty();
   }
 
   private ObjectId getRef(String refName) throws IOException {
diff --git a/plugins/webhooks b/plugins/webhooks
index d864a2c..2e5ec3b 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit d864a2c71e261b39b89ea9425280fad425a336f3
+Subproject commit 2e5ec3b3bcf5e7ba50edba9eca3c15c8057ad6c2
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 8fc3c1b..1640b7a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -314,8 +314,16 @@
     for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
       this.shortcuts.addLocal(
         {key: Key.ENTER, modifiers: [modifier]},
-        () => {
+        e => {
           this.save();
+          // We don't stop propagation for patchset comment
+          // (this.permanentEditingMode = true), but we stop it for normal
+          // comments. This prevents accidentally sending a reply when
+          // editing/saving them in the reply dialog.
+          if (!this.permanentEditingMode) {
+            e.preventDefault();
+            e.stopPropagation();
+          }
         },
         {preventDefault: false}
       );
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 82f520a..098b79a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -39,7 +39,7 @@
 import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {assertIsDefined} from '../../../utils/common-util';
-import {Modifier} from '../../../utils/dom-util';
+import {Key, Modifier} from '../../../utils/dom-util';
 import {SinonStubbedMember} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
@@ -596,6 +596,46 @@
       assert.isTrue(spy.called);
     });
 
+    suite('ctrl+ENTER  ', () => {
+      test('saves comment', async () => {
+        const spy = sinon.stub(element, 'save');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        pressKey(
+          element.textarea!.textarea!.textarea,
+          Key.ENTER,
+          Modifier.CTRL_KEY
+        );
+        assert.isTrue(spy.called);
+      });
+      test('propagates on patchset comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.permanentEditingMode = true;
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isFalse(stopPropagationStub.called);
+      });
+      test('does not propagate on normal comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isTrue(stopPropagationStub.called);
+      });
+    });
+
     test('save', async () => {
       const savePromise = mockPromise<DraftInfo>();
       const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index 19a6457..8b333e6 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -582,7 +582,8 @@
   gr-diff-row tr.diff-row,
   gr-diff-row td.content,
   gr-diff-section tbody.contextControl,
-  gr-diff-row td.blame {
+  gr-diff-row td.blame,
+  #diffHeader {
     -webkit-user-select: none;
     -moz-user-select: none;
     -ms-user-select: none;