Block submitting changes with unresolved comments for new sites by default

When initialising a new Gerrit site add a submit requirement to the
All-Projects project that blocks change submission if there are
unresolved comments.

User Stories:
* As a reviewer I want that changes can only be submitted when all my
  comments have been resolved.
* As a change owner I want that submitting my change is blocked until I
  have resolved all comments, so that I do not accidentally leave some
  comments unaddressed.

For sites where this behaviour is not wanted, the site administrators
can simply remove the submit requirement.

Release-Notes: Submitting changes with unresolved comments is blocked for new sites by default
Bug: Issue 317351858
Change-Id: I1e5688701080d2ce7fe44970cfbf1215e285e80f
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 314faf2..f00d205 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -34,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;
@@ -45,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;
@@ -132,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);
 
@@ -176,6 +184,18 @@
         .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
+  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 initDefaultAclsForRegisteredUsers(
       AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
     config.upsertAccessSection(
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 4f1dd3a..f692691 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -92,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() {
@@ -99,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;
@@ -140,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/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 9d2c9fe..3c26d3f 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -109,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(
@@ -118,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"));
   }
 
@@ -128,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/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/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/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 f73a250..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;
@@ -141,6 +142,7 @@
             .addBooleanProjectConfig(
                 BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
             .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -160,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 =
@@ -167,6 +189,7 @@
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
             .projectDescription(description)
             .initDefaultAcls(false)
+            .initDefaultSubmitRequirements(false)
             .build();
     allProjectsCreator.create(allProjectsInput);