Merge "Add Reviewer REST endpoint: Document how to add multiple reviewers at once"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3459001..3e75a53 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -99,8 +99,9 @@
 
 Gerrit comes with two predefined groups:
 
-* Administrators
-* Service Users
+* link:#administrators[Administrators]
+* link:#service_users[Service Users]
+* link:#blocked_users[Blocked Users]
 
 
 [[administrators]]
@@ -138,6 +139,15 @@
 
 Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'.
 
+[[blocked_users]]
+=== Blocked Users
+
+This is a predefined group, created on Gerrit site initialization, for which
+the link:#category_read[Read] access right is globally blocked.
+
+link:#administrators[Administrators] can add spammers to this group in order to
+block them from accessing Gerrit so that they cannot post any further spam.
+
 == Account Groups
 
 Account groups contain a list of zero or more user account members,
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 2367c21..a952d4e 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.block;
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AclUtil.rule;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
@@ -33,6 +34,8 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -44,6 +47,7 @@
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -131,11 +135,16 @@
       // init labels.
       input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
 
+      // init access sections.
       if (input.initDefaultAcls()) {
-        // init access sections.
         initDefaultAcls(config, input);
       }
 
+      // init submit requirement sections.
+      if (input.initDefaultSubmitRequirements()) {
+        initDefaultSubmitRequirements(config);
+      }
+
       // commit all the above configs as a commit in "refs/meta/config" branch of the All-Projects.
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
 
@@ -155,7 +164,10 @@
 
     config.upsertAccessSection(
         AccessSection.HEADS,
-        heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
+        heads -> {
+          initDefaultAclsForAnonymousUsers(heads, config);
+          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+        });
 
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
@@ -167,28 +179,44 @@
                         initDefaultAclsForServiceUsers(capabilities, config, serviceUsersGroup)));
 
     input
+        .blockedUsersGroup()
+        .ifPresent(blockedUsersGrouo -> initDefaultAclsForBlockedUsers(config, blockedUsersGrouo));
+
+    input
         .administratorsGroup()
         .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
-  private void initDefaultAclsForRegisteredUsers(
-      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
-    config.upsertAccessSection(
-        "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
+  private void initDefaultSubmitRequirements(ProjectConfig config) {
+    config.upsertSubmitRequirement(
+        SubmitRequirement.builder()
+            .setName("No-Unresolved-Comments")
+            .setDescription(
+                Optional.of("Changes that have unresolved comments are not submittable."))
+            .setApplicabilityExpression(SubmitRequirementExpression.of("has:unresolved"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  private void initDefaultAclsForAnonymousUsers(AccessSection.Builder heads, ProjectConfig config) {
+    grant(config, heads, Permission.READ, anonymous);
 
     config.upsertAccessSection(
         "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
+  }
 
+  private void initDefaultAclsForRegisteredUsers(
+      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, heads, Permission.READ, anonymous);
-    grant(config, heads, Permission.REVERT, registered);
 
     config.upsertAccessSection(
-        "refs/for/" + AccessSection.ALL,
-        magic -> {
-          grant(config, magic, Permission.PUSH, registered);
-          grant(config, magic, Permission.PUSH_MERGE, registered);
+        "refs/for/*",
+        refsFor -> {
+          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+          grant(config, refsFor, Permission.PUSH, registered);
+          grant(config, refsFor, Permission.PUSH_MERGE, registered);
         });
   }
 
@@ -201,6 +229,12 @@
     stream.add(rule(config, serviceUsersGroup));
   }
 
+  private void initDefaultAclsForBlockedUsers(
+      ProjectConfig config, GroupReference blockedUsersGroup) {
+    config.upsertAccessSection(
+        AccessSection.ALL, all -> block(config, all, Permission.READ, blockedUsersGroup));
+  }
+
   private void initDefaultAclsForAdmins(
       ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
     config.upsertAccessSection(
@@ -220,6 +254,7 @@
           grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
           grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
           grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+          grant(config, heads, Permission.REVERT, adminsGroup, owners);
         });
 
     config.upsertAccessSection(
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 8db5b1a..f692691 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -69,6 +69,9 @@
   /** The group which gets stream-events permission granted and appropriate properties set. */
   public abstract Optional<GroupReference> serviceUsersGroup();
 
+  /** The group for which read access gets blocked. */
+  public abstract Optional<GroupReference> blockedUsersGroup();
+
   /** The commit message used when commit the project config change. */
   public abstract Optional<String> commitMessage();
 
@@ -89,6 +92,9 @@
   /** Whether initializing default access sections in All-Projects. */
   public abstract boolean initDefaultAcls();
 
+  /** Whether default submit requirements should be initialized in All-Projects. */
+  public abstract boolean initDefaultSubmitRequirements();
+
   public abstract Builder toBuilder();
 
   public static Builder builder() {
@@ -96,7 +102,8 @@
         new AutoValue_AllProjectsInput.Builder()
             .codeReviewLabel(getDefaultCodeReviewLabel())
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
-            .initDefaultAcls(true);
+            .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true);
     DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(builder::addBooleanProjectConfig);
 
     return builder;
@@ -110,7 +117,9 @@
   public abstract static class Builder {
     public abstract Builder administratorsGroup(GroupReference adminGroup);
 
-    public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
+    public abstract Builder serviceUsersGroup(GroupReference serviceUsersGroup);
+
+    public abstract Builder blockedUsersGroup(GroupReference blockedUsersGroup);
 
     public abstract Builder commitMessage(String commitMessage);
 
@@ -135,6 +144,8 @@
     @UsedAt(UsedAt.Project.GOOGLE)
     public abstract Builder initDefaultAcls(boolean initDefaultACLs);
 
+    public abstract Builder initDefaultSubmitRequirements(boolean initDefaultSubmitRequirements);
+
     public abstract AllProjectsInput build();
   }
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 5b54cfd..cf3948a 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -92,11 +92,13 @@
     try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
       GroupReference admins = createGroupReference("Administrators");
       GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+      GroupReference blockedUsers = createGroupReference("Blocked Users");
 
       AllProjectsInput allProjectsInput =
           AllProjectsInput.builder()
               .administratorsGroup(admins)
               .serviceUsersGroup(serviceUsers)
+              .blockedUsersGroup(blockedUsers)
               .build();
       allProjectsCreator.create(allProjectsInput);
       // We have to create the All-Users repository before we can use it to store the groups in it.
@@ -105,6 +107,7 @@
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
         createAdminsGroup(allUsersRepo, admins);
         createServiceUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+        createBlockedUsersGroup(allUsersRepo, blockedUsers, admins.getUUID());
       }
     }
   }
@@ -141,6 +144,19 @@
     createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
+  private void createBlockedUsersGroup(
+      Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
+      throws IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(groupReference);
+    GroupDelta groupDelta =
+        GroupDelta.builder()
+            .setDescription("Blocked users. Add spammers to this group.")
+            .setOwnerGroupUUID(adminsGroupUuid)
+            .build();
+
+    createGroup(allUsersRepo, groupCreation, groupDelta);
+  }
+
   private void createGroup(
       Repository allUsersRepo, InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 7243bdf..5c8abf9 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,15 @@
       ImmutableList.of(
           "[access \"refs/*\"]",
           "  read = group Administrators",
+          "  read = block group Blocked Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
-          "[access \"refs/for/refs/*\"]",
           "  push = group Registered Users",
           "  pushMerge = group Registered Users",
           "[access \"refs/heads/*\"]",
           "  read = group Anonymous Users",
-          "  revert = group Registered Users",
+          "  revert = group Administrators",
+          "  revert = group Project Owners",
           "  create = group Administrators",
           "  create = group Project Owners",
           "  editTopicName = +force group Administrators",
@@ -108,6 +109,13 @@
           "  value = 0 No score",
           "  value = +1 Looks good to me, but someone else must approve",
           "  value = +2 Looks good to me, approved");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION =
+      ImmutableList.of(
+          "[submit-requirement \"No-Unresolved-Comments\"]",
+          "  description = Changes that have unresolved comments are not submittable.",
+          "  applicableIf = has:unresolved",
+          "  submittableIf = -has:unresolved",
+          "  canOverrideInChildProjects = false");
 
   public static String getDefaultAllProjectsWithAllDefaultSections() {
     return Streams.stream(
@@ -117,7 +125,8 @@
                 DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
                 DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
                 DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
-                DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
         .collect(Collectors.joining("\n"));
   }
 
@@ -127,6 +136,19 @@
                 DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
                 DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
                 DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
+  public static String getAllProjectsWithoutDefaultSubmitRequirements() {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+                DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
                 DEFAULT_ALL_PROJECTS_LABEL_SECTION))
         .collect(Collectors.joining("\n"));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index ead4c40..f462614 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
@@ -26,6 +28,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.AccountGroup;
@@ -33,6 +36,7 @@
 import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -63,6 +67,7 @@
   private ContributorAgreement caAutoVerify;
   private ContributorAgreement caNoAutoVerify;
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
@@ -298,6 +303,11 @@
     gApi.changes().id(change.changeId).current().submit(new SubmitInput());
 
     // Revert in excluded project is allowed even when CLA is required but not signed
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
     gApi.changes().id(change.changeId).revert();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
new file mode 100644
index 0000000..fc8eaed
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
@@ -0,0 +1,79 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.Comparator;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class DefaultSubmitRequirementsIT extends AbstractDaemonTest {
+  /**
+   * Tests the "No-Unresolved-Comments" submit requirement that is created during the site
+   * initialization.
+   */
+  @Test
+  public void cannotSubmitChangeWithUnresolvedComment() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+    PushOneCommit.Result r =
+        createChange(repo, "master", "Add a file", "foo", "content", /* topic= */ null);
+    String changeId = r.getChangeId();
+    CommentInfo commentInfo =
+        addComment(changeId, "foo", "message", /* unresolved= */ true, /* inReplyTo= */ null);
+    assertThat(commentInfo.unresolved).isTrue();
+    approve(changeId);
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %s: submit requirement 'No-Unresolved-Comments' is unsatisfied.",
+                r.getChange().getId().get()));
+
+    // Resolve the comment and check that the change can be submitted now.
+    CommentInfo commentInfo2 =
+        addComment(
+            changeId, "foo", "reply", /* unresolved= */ false, /* inReplyTo= */ commentInfo.id);
+    assertThat(commentInfo2.unresolved).isFalse();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @CanIgnoreReturnValue
+  private CommentInfo addComment(
+      String changeId, String file, String message, boolean unresolved, @Nullable String inReplyTo)
+      throws Exception {
+    ReviewInput in = new ReviewInput();
+    CommentInput commentInput = new CommentInput();
+    commentInput.path = file;
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.unresolved = unresolved;
+    commentInput.inReplyTo = inReplyTo;
+    in.comments = ImmutableMap.of(file, ImmutableList.of(commentInput));
+    gApi.changes().id(changeId).current().review(in);
+
+    return gApi.changes().id(changeId).commentsRequest().getAsList().stream()
+        .filter(commentInfo -> commentInput.message.equals(commentInfo.message))
+        // if there are multiple comments with the same message, take the one was created last
+        .max(
+            Comparator.comparing(commentInfo1 -> commentInfo1.updated.toInstant().getEpochSecond()))
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format("comment '%s' not found", commentInput.message)));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index cd3e76d..10dd5e3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+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.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -413,6 +414,12 @@
   @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void revertWithNonVisibleUsers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     // Define readable names for the users we use in this test.
     TestAccount reverter = user;
     TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 42af666..a5d86ce 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -92,6 +92,7 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
@@ -103,6 +104,11 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
 
+  @Before
+  public void setup() throws RestApiException {
+    removeDefaultSubmitRequirements();
+  }
+
   @Test
   public void submitRecords() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -3160,4 +3166,8 @@
     r.assertOkStatus();
     return r;
   }
+
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
index e388dd1..6d4da66 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -582,7 +582,7 @@
 
     infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
 
-    assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+    assertThat(names(infos)).containsExactly("No-Unresolved-Comments", "base-sr", "sr-1", "sr-2");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 6c5febd..c18cd55 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -87,7 +87,8 @@
   @Inject private IndexOperations.Change changeIndexOperations;
 
   private AccountGroup.UUID admins;
-  private AccountGroup.UUID nonInteractiveUsers;
+  private AccountGroup.UUID serviceUsers;
+  private AccountGroup.UUID blockedUsers;
 
   private RevCommit rcMaster;
   private RevCommit rcBranch;
@@ -118,7 +119,8 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+    serviceUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+    blockedUsers = groupUuid("Blocked Users");
     setUpPermissions();
     setUpChanges();
   }
@@ -1239,7 +1241,8 @@
       assertThat(getGroupRefs(git))
           .containsExactly(
               RefNames.refsGroups(admins),
-              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(serviceUsers),
+              RefNames.refsGroups(blockedUsers),
               RefNames.refsGroups(users));
     }
   }
@@ -1261,7 +1264,8 @@
       assertThat(getGroupRefs(git))
           .containsExactly(
               RefNames.refsGroups(admins),
-              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(serviceUsers),
+              RefNames.refsGroups(blockedUsers),
               RefNames.refsGroups(users));
     }
   }
@@ -1413,7 +1417,8 @@
             RefNames.REFS_EXTERNAL_IDS,
             RefNames.REFS_GROUPNAMES,
             RefNames.refsGroups(admins),
-            RefNames.refsGroups(nonInteractiveUsers),
+            RefNames.refsGroups(serviceUsers),
+            RefNames.refsGroups(blockedUsers),
             RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS,
             RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS,
             RefNames.REFS_CONFIG,
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 4453345..e58757b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -34,6 +34,6 @@
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
     assertThat(groupMap.keySet())
-        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
+        .containsExactly("Administrators", "Blocked Users", ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 2ee5360..d88db52 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -2369,6 +2369,12 @@
 
   @Test
   public void revertChangeByOwner() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, sc.owner);
 
@@ -2394,6 +2400,12 @@
 
   @Test
   public void revertChangeByOwnerCcingSelf() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
 
@@ -2420,6 +2432,12 @@
 
   @Test
   public void revertChangeByOther() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, other);
 
@@ -2446,6 +2464,12 @@
 
   @Test
   public void revertChangeByOtherCcingSelf() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, other, CC_ON_OWN_COMMENTS);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
index 50fa3b2..3041744 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
@@ -64,6 +65,7 @@
 
   @Before
   public void setUp() throws Exception {
+    removeDefaultSubmitRequirements();
     TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
     configSubmitRequirement(
         project,
@@ -242,4 +244,8 @@
                 .count())
         .isEqualTo(1);
   }
+
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0c24b14..b5a3b66 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -75,6 +75,7 @@
 
   @Before
   public void setUp() throws Exception {
+    removeDefaultSubmitRequirements();
     PushOneCommit.Result pushResult =
         createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
     changeData = pushResult.getChange();
@@ -976,6 +977,10 @@
         .build();
   }
 
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
+
   /** Submit requirement predicate that always throws an error on match. */
   static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
       implements ChangeIsOperandFactory {
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 6c79c43..3fae3ad 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultSubmitRequirements;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
@@ -89,11 +90,13 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference("Blocked Users");
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
-            .serviceUsersGroup(batchUsersGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -139,6 +142,7 @@
             .addBooleanProjectConfig(
                 BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
             .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -158,6 +162,26 @@
   }
 
   @Test
+  public void createAllProjectsWithoutInitializingDefaultSubmitRequirements() throws Exception {
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference("Blocked Users");
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder()
+            .administratorsGroup(adminsGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
+            .initDefaultSubmitRequirements(false)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getAllProjectsWithoutDefaultSubmitRequirements());
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  @Test
   public void createAllProjectsOnlyInitializingProjectDescription() throws Exception {
     String description = "a project.config with just a project description";
     AllProjectsInput allProjectsInput =
@@ -165,6 +189,7 @@
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
             .projectDescription(description)
             .initDefaultAcls(false)
+            .initDefaultSubmitRequirements(false)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
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 ad3e15c..30f4bf8 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -458,7 +458,9 @@
     // app.
     assign(
       window.location,
-      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+      `${basePath}/login/${encodeURIComponent(
+        returnUrl.substring(basePath.length)
+      )}`
     );
   }
 
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 51613e8..863f4f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -87,6 +87,8 @@
 import {getFileExtension} from '../../../utils/file-util';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {deepEqual} from '../../../utils/deep-util';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {waitUntil} from '../../../utils/async-util';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -155,6 +157,9 @@
   @query('#confirmDeleteCommentDialog')
   confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
 
+  @query('#suggestionDiffPreview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
   @property({type: Object})
   comment?: Comment;
 
@@ -1059,6 +1064,7 @@
 
     if (this.generatedFixSuggestion) {
       return html`<gr-suggestion-diff-preview
+        id="suggestionDiffPreview"
         .fixSuggestionInfo=${this.generatedFixSuggestion}
       ></gr-suggestion-diff-preview>`;
     } else if (this.generatedSuggestion) {
@@ -1269,7 +1275,13 @@
       return;
     }
     this.generatedFixSuggestion = suggestion;
-    this.autoSaveTrigger$.next();
+    try {
+      await waitUntil(() => this.getFixSuggestions() !== undefined);
+      this.autoSaveTrigger$.next();
+    } catch (error) {
+      // Error is ok in some cases like quick save by user.
+      console.warn(error);
+    }
   }
 
   private renderRobotActions() {
@@ -1682,7 +1694,7 @@
       isError(this.comment) ||
       this.messageText.trimEnd() !== this.comment.message ||
       this.unresolved !== this.comment.unresolved ||
-      !deepEqual(this.comment.fix_suggestions, this.getFixSuggestions())
+      this.isFixSuggestionChanged()
     );
   }
 
@@ -1690,15 +1702,22 @@
   private rawSave(options: {showToast: boolean}) {
     assert(isDraft(this.comment), 'only drafts are editable');
     assert(!isSaving(this.comment), 'saving already in progress');
-    return this.getCommentsModel().saveDraft(
-      {
-        ...this.comment,
-        message: this.messageText.trimEnd(),
-        unresolved: this.unresolved,
-        fix_suggestions: this.getFixSuggestions(),
-      },
-      options.showToast
-    );
+    const draft: DraftInfo = {
+      ...this.comment,
+      message: this.messageText.trimEnd(),
+      unresolved: this.unresolved,
+    };
+    if (this.isFixSuggestionChanged()) {
+      draft.fix_suggestions = this.getFixSuggestions();
+    }
+    return this.getCommentsModel().saveDraft(draft, options.showToast);
+  }
+
+  isFixSuggestionChanged(): boolean {
+    // Check to not change fix suggestion when draft is not being edited only
+    // when user quickly disable generating suggestions and click save
+    if (!this.editing && this.generateSuggestion) return false;
+    return !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions());
   }
 
   getFixSuggestions(): FixSuggestionInfo[] | undefined {
@@ -1708,6 +1727,13 @@
     if (!this.generatedFixSuggestion) return undefined;
     // Disable fix suggestions when the comment already has a user suggestion
     if (this.comment && hasUserSuggestion(this.comment)) return undefined;
+    // we ignore fixSuggestions until they are previewed.
+    if (
+      this.suggestionDiffPreview &&
+      !this.suggestionDiffPreview?.previewed &&
+      !this.suggestionLoading
+    )
+      return undefined;
     return [this.generatedFixSuggestion];
   }
 
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 aebc638..ef02c95 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
@@ -1081,7 +1081,7 @@
       await element.updateComplete;
       assert.dom.equal(
         queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        /* HTML */ '<gr-suggestion-diff-preview id="suggestionDiffPreview"> </gr-suggestion-diff-preview>'
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 923a00e..8314912 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -62,6 +62,9 @@
   @property({type: Boolean})
   showAddSuggestionButton = false;
 
+  @property({type: Boolean, attribute: 'previewed', reflect: true})
+  previewed = false;
+
   @property({type: String})
   uuid?: string;
 
@@ -270,6 +273,7 @@
     )
       return;
 
+    this.previewed = false;
     this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     const res = await this.restApiService.getFixPreview(
       this.changeNum,
@@ -287,6 +291,7 @@
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
       this.previewLoadedFor = this.fixSuggestionInfo;
+      this.previewed = true;
     }
 
     return res;
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
 import '../api/embed';
 import '../scripts/bundled-polymer';
 import './diff/gr-diff/gr-diff';
+import './gr-textarea';
 import './diff/gr-diff-cursor/gr-diff-cursor';
 import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
 import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..35dc2d1
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,788 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+  return new Promise(resolve => {
+    requestAnimationFrame(() => {
+      resolve();
+    });
+  });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+  const div = document.createElement('div');
+  try {
+    div.contentEditable = 'PLAINTEXT-ONLY';
+    return div.contentEditable === 'plaintext-only';
+  } catch (e) {
+    return false;
+  }
+}
+
+/** Input custom event detail object. */
+export interface InputEventDetail {
+  value: string;
+}
+
+/** Cursor position change custom event detail object.
+ *
+ * The current position of the cursor.
+ */
+export interface CursorPositionChangeEventDetail {
+  position: number;
+}
+
+/** hint shown custom event detail object */
+export interface HintShownEventDetail {
+  hint: string;
+}
+
+/** hint dismissed custom event detail object */
+export interface HintDismissedEventDetail {
+  hint: string;
+}
+
+/** hint applied custom event detail object */
+export interface HintAppliedEventDetail {
+  hint: string;
+  oldValue: string;
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+  'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement {
+  // editableDivElement is available right away where it may be undefined. This
+  // is used for calls for scrollTop as if it is undefined then we can fallback
+  // to 0. For other usecases use editableDiv.
+  @query('.editableDiv')
+  private readonly editableDivElement?: HTMLDivElement;
+
+  @queryAsync('.editableDiv')
+  private readonly editableDiv?: Promise<HTMLDivElement>;
+
+  @property({type: Boolean, reflect: true}) disabled = false;
+
+  @property({type: String, reflect: true}) placeholder: string | undefined;
+
+  /**
+   * The hint is shown as a autocomplete string which can be added by pressing
+   * TAB.
+   *
+   * The hint is shown
+   *  1. At the cursor position, only when cursor position is at the end of
+   *     textarea content.
+   *  2. When textarea has focus.
+   *  3. When selection inside the textarea is collapsed.
+   *
+   * When hint is applied listen for hintApplied event and remove the hint
+   * as component property to avoid showing the hint again.
+   */
+  @property({type: String})
+  set hint(newHint) {
+    if (this.hint !== newHint) {
+      this.innerHint = newHint;
+      this.updateHintInDomIfRendered();
+    }
+  }
+
+  get hint() {
+    return this.innerHint;
+  }
+
+  /**
+   * Show hint is shown as placeholder which people can autocomplete to.
+   *
+   * This takes precedence over hint property.
+   * It is shown even when textarea has no focus.
+   * This is shown only when textarea is blank.
+   */
+  @property({type: String}) placeholderHint: string | undefined;
+
+  /**
+   * Sets the value for textarea and also renders it in dom if it is different
+   * from last rendered value.
+   *
+   * To prevent cursor position from jumping to front of text even when value
+   * remains same, Check existing value before triggering the update and only
+   * update when there is a change.
+   *
+   * Also .innerText binding can't be used for security reasons.
+   */
+  @property({type: String})
+  set value(newValue) {
+    if (this.ignoreValue && this.ignoreValue === newValue) {
+      return;
+    }
+    const oldVal = this.value;
+    if (oldVal !== newValue) {
+      this.innerValue = newValue;
+      this.updateValueInDom();
+    }
+  }
+
+  get value() {
+    return this.innerValue;
+  }
+
+  /**
+   * This value will be ignored by textarea and is not set.
+   */
+  @property({type: String}) ignoreValue: string | undefined;
+
+  /**
+   * Sets cursor at the end of content on focus.
+   */
+  @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+  /**
+   * Enables save shortcut.
+   *
+   * On S key down with control or meta key enabled is exposed with output event
+   * 'saveShortcut'.
+   */
+  @property({type: Boolean}) enableSaveShortcut = false;
+
+  /*
+   * Is textarea focused. This is a readonly property.
+   */
+  get isFocused(): boolean {
+    return this.focused;
+  }
+
+  /**
+   * Native element for editable div.
+   */
+  get nativeElement() {
+    return this.editableDivElement;
+  }
+
+  /**
+   * Scroll Top for editable div.
+   */
+  override get scrollTop() {
+    return this.editableDivElement?.scrollTop ?? 0;
+  }
+
+  private innerValue: string | undefined;
+
+  private innerHint: string | undefined;
+
+  private focused = false;
+
+  private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          position: relative;
+          width: 100%;
+        }
+
+        :host([disabled]) {
+          .editableDiv {
+            background-color: var(--input-field-disabled-bg, lightgrey);
+            color: var(--text-disabled, black);
+            cursor: default;
+          }
+        }
+
+        .editableDiv {
+          background-color: var(--input-field-bg, white);
+          border: 2px solid var(--onedev-textarea-border-color, white);
+          border-radius: 4px;
+          box-sizing: border-box;
+          color: var(--text-default, black);
+          max-height: var(--onedev-textarea-max-height, 16em);
+          min-height: var(--onedev-textarea-min-height, 4em);
+          overflow-x: auto;
+          padding: 12px;
+          white-space: pre-wrap;
+          width: 100%;
+
+          &:focus-visible {
+            border-color: var(--onedev-textarea-focus-outline-color, black);
+            outline: none;
+          }
+
+          &:empty::before {
+            content: attr(data-placeholder);
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+          }
+
+          &.hintShown:empty::after,
+          .autocomplete-hint:empty::after {
+            background-color: var(--secondary-bg-color, white);
+            border: 1px solid var(--text-secondary, lightgrey);
+            border-radius: 2px;
+            content: 'tab';
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+            font-size: 10px;
+            line-height: 10px;
+            margin-left: 4px;
+            padding: 1px 3px;
+          }
+
+          .autocomplete-hint {
+            &:empty::before {
+              content: attr(data-hint);
+              color: var(--text-secondary, lightgrey);
+            }
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const isHintShownAsPlaceholder =
+      (!this.disabled && this.placeholderHint) ?? false;
+
+    const placeholder = isHintShownAsPlaceholder
+      ? this.placeholderHint
+      : this.placeholder;
+    const ariaPlaceholder = isHintShownAsPlaceholder
+      ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+      : placeholder;
+
+    const classes = classMap({
+      editableDiv: true,
+      hintShown: isHintShownAsPlaceholder,
+    });
+
+    // Chrome supports non-standard "contenteditable=plaintext-only",
+    // which prevents HTML from being inserted into a contenteditable element.
+    // https://github.com/w3c/editing/issues/162
+    return html`<div
+      aria-disabled=${this.disabled}
+      aria-multiline="true"
+      aria-placeholder=${ifDefined(ariaPlaceholder)}
+      data-placeholder=${ifDefined(placeholder)}
+      class=${classes}
+      contenteditable=${this.contentEditableAttributeValue}
+      dir="ltr"
+      role="textbox"
+      @input=${this.onInput}
+      @focus=${this.onFocus}
+      @blur=${this.onBlur}
+      @keydown=${this.handleKeyDown}
+      @keyup=${this.handleKeyUp}
+      @mouseup=${this.handleMouseUp}
+      @scroll=${this.handleScroll}
+    ></div>`;
+  }
+
+  /**
+   * Focuses the textarea.
+   */
+  override async focus() {
+    const editableDivElement = await this.editableDiv;
+    const isFocused = this.isFocused;
+    editableDivElement?.focus?.();
+    // If already focused, do not change the cursor position.
+    if (this.putCursorAtEndOnFocus && !isFocused) {
+      await this.putCursorAtEnd();
+    }
+  }
+
+  /**
+   * Puts the cursor at the end of existing content.
+   * Scrolls the content of textarea towards the end.
+   */
+  async putCursorAtEnd() {
+    const editableDivElement = await this.editableDiv;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+
+    const range = document.createRange();
+    editableDivElement.focus();
+    range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    await this.onCursorPositionChange(null);
+  }
+
+  /**
+   * Sets cursor position to given position and scrolls the content to cursor
+   * position.
+   *
+   * If position is out of bounds of value of textarea then cursor is places at
+   * end of content of textarea.
+   */
+  async setCursorPosition(position: number) {
+    // This will keep track of remaining offset to place the cursor.
+    let remainingOffset = position;
+    let isOnFreshLine = true;
+    let nodeToFocusOn: Node | null = null;
+    const editableDivElement = await this.editableDiv;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+    editableDivElement.focus();
+    const findNodeToFocusOn = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+        let currentNodeLength = 0;
+
+        if (childNode.nodeName === 'BR') {
+          currentNodeLength++;
+          isOnFreshLine = true;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          currentNodeLength++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+          currentNodeLength += childNode.textContent.length;
+        }
+
+        if (remainingOffset <= currentNodeLength) {
+          nodeToFocusOn = childNode;
+          break;
+        } else {
+          remainingOffset -= currentNodeLength;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findNodeToFocusOn(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    // Find the node to focus on.
+    findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+    await this.setFocusOnNode(
+      selection,
+      editableDivElement,
+      nodeToFocusOn,
+      remainingOffset
+    );
+  }
+
+  /**
+   * Replaces text from start and end cursor position.
+   */
+  setRangeText(replacement: string, start: number, end: number) {
+    const pre = this.value?.substring(0, start) ?? '';
+    const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+    this.value = pre + replacement + post;
+    this.setCursorPosition(pre.length + replacement.length);
+  }
+
+  private get contentEditableAttributeValue() {
+    return this.disabled
+      ? 'false'
+      : this.isPlaintextOnlySupported
+      ? ('plaintext-only' as unknown as 'true')
+      : 'true';
+  }
+
+  private async setFocusOnNode(
+    selection: Selection,
+    editableDivElement: Node,
+    nodeToFocusOn: Node | null,
+    remainingOffset: number
+  ) {
+    const range = document.createRange();
+    // If node is null or undefined then fallback to focus event which will put
+    // cursor at the end of content.
+    if (nodeToFocusOn === null) {
+      range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    }
+    // If node to focus is BR then focus offset is number of nodes.
+    else if (nodeToFocusOn.nodeName === 'BR') {
+      const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+      range.setEnd(nextNode, 0);
+    } else {
+      range.setStart(nodeToFocusOn, remainingOffset);
+    }
+
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    // Scroll the content to cursor position.
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    await this.onCursorPositionChange(null);
+  }
+
+  private async onInput(event: Event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+
+    const value = await this.getValue();
+    this.innerValue = value;
+
+    this.dispatchEvent(
+      new CustomEvent('input', {
+        detail: {
+          value: this.value,
+        },
+      })
+    );
+  }
+
+  private async onFocus(event: Event) {
+    this.focused = true;
+    await this.onCursorPositionChange(event);
+  }
+
+  private async onBlur(event: Event) {
+    this.focused = false;
+    this.removeHintSpanIfShown();
+    await this.onCursorPositionChange(event);
+  }
+
+  private async handleKeyDown(event: KeyboardEvent) {
+    if (
+      event.key === 'Tab' &&
+      !event.shiftKey &&
+      !event.ctrlKey &&
+      !event.metaKey
+    ) {
+      await this.handleTabKeyPress(event);
+      return;
+    }
+    if (
+      this.enableSaveShortcut &&
+      event.key === 's' &&
+      (event.ctrlKey || event.metaKey)
+    ) {
+      event.preventDefault();
+      this.dispatchEvent(new CustomEvent('saveShortcut'));
+    }
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private async handleKeyUp(event: KeyboardEvent) {
+    await this.onCursorPositionChange(event);
+  }
+
+  private async handleMouseUp(event: MouseEvent) {
+    await this.onCursorPositionChange(event);
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private handleScroll() {
+    this.dispatchEvent(new CustomEvent('scroll'));
+  }
+
+  private async handleTabKeyPress(event: KeyboardEvent) {
+    const oldValue = this.value;
+    if (this.placeholderHint && !oldValue) {
+      event.preventDefault();
+      await this.appendHint(this.placeholderHint, event);
+    } else if (this.hasHintSpan()) {
+      event.preventDefault();
+      await this.appendHint(this.hint!, event);
+    }
+  }
+
+  private async appendHint(hint: string, event: Event) {
+    const oldValue = this.value ?? '';
+    const newValue = oldValue + hint;
+
+    this.value = newValue;
+    await this.putCursorAtEnd();
+    await this.onInput(event);
+
+    this.dispatchEvent(
+      new CustomEvent('hintApplied', {
+        detail: {
+          hint,
+          oldValue,
+        },
+      })
+    );
+  }
+
+  private async toggleHintVisibilityIfAny() {
+    // Wait for the next animation frame so that entered key is processed and
+    // available in dom.
+    await animationFrame();
+
+    const editableDivElement = await this.editableDiv;
+    const currentValue = (await this.getValue()) ?? '';
+    const cursorPosition = await this.getCursorPosition();
+    if (
+      !editableDivElement ||
+      (this.placeholderHint && !currentValue) ||
+      !this.hint ||
+      !this.isFocused ||
+      cursorPosition !== currentValue.length
+    ) {
+      this.removeHintSpanIfShown();
+      return;
+    }
+
+    const hintSpan = this.hintSpan();
+    if (!hintSpan) {
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+      return;
+    }
+
+    const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+    if (oldHint !== this.hint) {
+      this.removeHintSpanIfShown();
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+    }
+  }
+
+  private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+    const hintSpan = document.createElement('span');
+    hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+    hintSpan.setAttribute('role', 'alert');
+    hintSpan.setAttribute(
+      'aria-label',
+      'Suggestion: ' + hint + ' Press TAB to accept it.'
+    );
+    hintSpan.dataset['hint'] = hint;
+    editableDivElement.appendChild(hintSpan);
+    this.dispatchEvent(
+      new CustomEvent('hintShown', {
+        detail: {
+          hint,
+        },
+      })
+    );
+  }
+
+  private removeHintSpanIfShown() {
+    const hintSpan = this.hintSpan();
+    if (hintSpan) {
+      hintSpan.remove();
+      this.dispatchEvent(
+        new CustomEvent('hintDismissed', {
+          detail: {
+            hint: (hintSpan as HTMLElement).dataset['hint'],
+          },
+        })
+      );
+    }
+  }
+
+  private hasHintSpan() {
+    return !!this.hintSpan();
+  }
+
+  private hintSpan() {
+    return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+  }
+
+  private async onCursorPositionChange(event: Event | null) {
+    event?.preventDefault();
+    event?.stopImmediatePropagation();
+
+    this.dispatchEvent(
+      new CustomEvent('cursorPositionChange', {
+        detail: {
+          position: await this.getCursorPosition(),
+        },
+      })
+    );
+  }
+
+  private async updateValueInDom() {
+    const editableDivElement = await this.editableDiv;
+    if (editableDivElement) {
+      editableDivElement.innerText = this.value || '';
+    }
+  }
+
+  private async updateHintInDomIfRendered() {
+    // Wait for editable div to render then process the hint.
+    await this.editableDiv;
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private async getValue() {
+    const editableDivElement = await this.editableDiv;
+    if (editableDivElement) {
+      const [output] = this.parseText(editableDivElement, false, true);
+      return output;
+    }
+    return '';
+  }
+
+  private parseText(
+    node: Node,
+    isLastBr: boolean,
+    isFirst: boolean
+  ): [string, boolean] {
+    let textValue = '';
+    let output = '';
+    if (node.nodeName === 'BR') {
+      return ['\n', true];
+    }
+
+    if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+      return [node.textContent, false];
+    }
+
+    if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+      textValue = '\n';
+    }
+
+    isLastBr = false;
+
+    for (let i = 0; i < node.childNodes?.length; i++) {
+      [output, isLastBr] = this.parseText(
+        node.childNodes[i],
+        isLastBr,
+        i === 0
+      );
+      textValue += output;
+    }
+    return [textValue, isLastBr];
+  }
+
+  private async getCursorPosition() {
+    const selection = this.getSelection();
+    const editableDivElement = await this.editableDiv;
+
+    // Cursor position is -1 (not available) if
+    //
+    // If textarea is not rendered.
+    // If textarea is not focused
+    // There is no accessible selection object.
+    // This is not a collapsed selection.
+    if (
+      !editableDivElement ||
+      !this.focused ||
+      !selection ||
+      selection.focusNode === null ||
+      !selection.isCollapsed
+    ) {
+      return -1;
+    }
+
+    let cursorPosition = 0;
+    let isOnFreshLine = true;
+
+    const findCursorPosition = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+
+        if (childNode.nodeName === 'BR') {
+          cursorPosition++;
+          isOnFreshLine = true;
+          continue;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          cursorPosition++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode === selection.focusNode) {
+          cursorPosition += selection.focusOffset;
+          break;
+        } else if (childNode.nodeType === 3 && childNode.textContent) {
+          cursorPosition += childNode.textContent.length;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findCursorPosition(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    if (editableDivElement === selection.focusNode) {
+      // If focus node is the top textarea then focusOffset is the number of
+      // child nodes before the cursor position.
+      const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+        0,
+        selection.focusOffset
+      );
+      findCursorPosition(partOfNodes);
+    } else {
+      findCursorPosition(Array.from(editableDivElement.childNodes));
+    }
+
+    return cursorPosition;
+  }
+
+  /** Gets the current selection, preferring the shadow DOM selection. */
+  private getSelection(): Selection | undefined | null {
+    // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+    return this.shadowRoot?.getSelection?.();
+  }
+
+  private scrollToCursorPosition(range: Range) {
+    const tempAnchorEl = document.createElement('br');
+    range.insertNode(tempAnchorEl);
+
+    tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+    tempAnchorEl.remove();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-textarea': GrTextarea;
+  }
+  interface HTMLElementEventMap {
+    // prettier-ignore
+    'saveShortcut': CustomEvent<{}>;
+    // prettier-ignore
+    'hintApplied': CustomEvent<HintAppliedEventDetail>;
+    // prettier-ignore
+    'hintShown': CustomEvent<HintShownEventDetail>;
+    // prettier-ignore
+    'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+    // prettier-ignore
+    'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..7af3697
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {
+  AUTOCOMPLETE_HINT_CLASS,
+  CursorPositionChangeEventDetail,
+  GrTextarea,
+} from './gr-textarea';
+
+async function rafPromise() {
+  return new Promise(res => {
+    requestAnimationFrame(res);
+  });
+}
+
+suite('gr-textarea test', () => {
+  let element: GrTextarea;
+
+  setup(async () => {
+    element = await fixture(html` <gr-textarea> </gr-textarea>`);
+  });
+
+  test('text area is registered correctly', () => {
+    assert.instanceOf(element, GrTextarea);
+  });
+
+  test('when disabled textarea have contenteditable set to false', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+  });
+
+  test('when disabled textarea have aria-disabled set', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+  });
+
+  test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+    const placeholder = 'A sample placehodler...';
+    element.placeholder = placeholder;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+  });
+
+  test('renders the value', async () => {
+    const value = 'Some value';
+    element.value = value;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.innerText, value);
+  });
+
+  test('streams change event when editable div has input event', async () => {
+    const value = 'Some value \n other value';
+    const INPUT_EVENT = 'input';
+    let changeCalled = false;
+
+    element.addEventListener(INPUT_EVENT, () => {
+      changeCalled = true;
+    });
+
+    const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+
+    editableDiv.innerText = value;
+    editableDiv.dispatchEvent(new Event('input'));
+    await changeEventPromise;
+
+    assert.isTrue(changeCalled);
+  });
+
+  test('does not have focus by default', async () => {
+    assert.isFalse(element.isFocused);
+  });
+
+  test('when focused, isFocused is set to true', async () => {
+    await element.focus();
+    assert.isTrue(element.isFocused);
+  });
+
+  test('when cursor position is set to 0', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    await element.setCursorPosition(0);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 0);
+  });
+
+  test('when cursor position is set to 1', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some value';
+    await element.updateComplete;
+    await element.setCursorPosition(1);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 1);
+  });
+
+  test('when cursor position is set to new line', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some \n\n\n value';
+    await element.updateComplete;
+    await element.setCursorPosition(7);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 7);
+  });
+
+  test('when textarea is empty, placeholder hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+  });
+
+  test('when TAB is pressed, placeholder hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, placeholderHint);
+  });
+
+  test('when cursor is at end, hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    await element.updateComplete;
+    element.value = oldValue;
+    await element.putCursorAtEnd();
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await element.updateComplete;
+    await rafPromise();
+
+    const spanHintElement = editableDiv?.querySelector(
+      '.' + AUTOCOMPLETE_HINT_CLASS
+    ) as HTMLSpanElement;
+    const styles = window.getComputedStyle(spanHintElement, ':before');
+    assert.equal(styles['content'], '"' + hint + '"');
+  });
+
+  test('when TAB is pressed, hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    element.value = oldValue;
+    await element.updateComplete;
+    await element.putCursorAtEnd();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await rafPromise();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, oldValue + hint);
+  });
+});
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 49b8edf..603e6c8 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ac3dbac..241d59b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index b3408d1..00132bd 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 2f72352..5a21ab7 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index 23ccefb..181159e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.10.0-SNAPSHOT"
+GERRIT_VERSION = "3.11.0-SNAPSHOT"