Merge "Label config: Allow to configure individual votes as sticky"
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 193a96f..a3b9d0b 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -344,6 +344,13 @@
 
 Defaults to true.
 
+[[label_copyValue]]
+=== `label.Label-Name.copyValue`
+
+Value that should be copied forward when a new patch set is uploaded.
+This can be used to enable sticky votes. Can be specified multiple
+times. By default not set.
+
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 2f788be..a4e27b3 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3916,6 +3916,8 @@
 |`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
+|`copy_values`   |optional|
+List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|`false` if not set|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -3981,6 +3983,8 @@
 |`copy_all_scores_on_merge_first_parent_update`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
+|`copy_values`   |optional|
+List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|optional|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index 14b8310..964cf67 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -14,14 +14,17 @@
 
 package com.google.gerrit.common.data;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.collectingAndThen;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -37,6 +40,7 @@
   public static final boolean DEF_COPY_ANY_SCORE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
+  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
@@ -104,6 +108,7 @@
   protected boolean copyAllScoresOnTrivialRebase;
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
+  protected ImmutableList<Short> copyValues;
   protected boolean allowPostSubmit;
   protected boolean ignoreSelfApproval;
   protected short defaultValue;
@@ -144,6 +149,7 @@
     setCopyAnyScore(DEF_COPY_ANY_SCORE);
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
+    setCopyValues(DEF_COPY_VALUES);
     setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
     setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
 
@@ -298,6 +304,14 @@
     this.copyAllScoresIfNoChange = copyAllScoresIfNoChange;
   }
 
+  public ImmutableList<Short> getCopyValues() {
+    return copyValues;
+  }
+
+  public void setCopyValues(Collection<Short> copyValues) {
+    this.copyValues = copyValues.stream().sorted().collect(toImmutableList());
+  }
+
   public boolean isMaxNegative(PatchSetApproval ca) {
     return maxNegative == ca.value();
   }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 64c3997..f552566 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -32,6 +32,7 @@
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
   public Boolean copyAllScoresOnMergeFirstParentUpdate;
+  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 0523f61..23d5df1 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -31,6 +31,7 @@
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
   public Boolean copyAllScoresOnMergeFirstParentUpdate;
+  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 44b0529..a4c9f2a 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -101,6 +101,8 @@
       return true;
     } else if (type.isCopyAnyScore()) {
       return true;
+    } else if (type.getCopyValues().contains(psa.value())) {
+      return true;
     }
     switch (kind) {
       case MERGE_FIRST_PARENT_UPDATE:
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 2ecd8c2..0452d0b 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -40,6 +40,7 @@
     label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
     label.copyAllScoresOnMergeFirstParentUpdate =
         toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
+    label.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
     label.allowPostSubmit = toBoolean(labelType.allowPostSubmit());
     label.ignoreSelfApproval = toBoolean(labelType.ignoreSelfApproval());
     return label;
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index fa877af..4ab583d 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -109,6 +109,7 @@
   public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
   public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
   public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  public static final String KEY_COPY_VALUE = "copyValue";
   public static final String KEY_VALUE = "value";
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
@@ -1014,6 +1015,27 @@
               name,
               KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
+      Set<Short> copyValues = new HashSet<>();
+      for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
+        try {
+          short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
+          if (!copyValues.add(copyValue)) {
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    String.format(
+                        "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
+          }
+        } catch (IllegalArgumentException notValue) {
+          error(
+              new ValidationError(
+                  PROJECT_CONFIG,
+                  String.format(
+                      "Invalid %s \"%s\" for label \"%s\": %s",
+                      KEY_COPY_VALUE, value, name, notValue.getMessage())));
+        }
+      }
+      label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
@@ -1492,6 +1514,11 @@
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+      rc.setStringList(
+          LABEL,
+          name,
+          KEY_COPY_VALUE,
+          label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
       setBooleanConfigKey(
           rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 5d51527..a85ad39 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -191,6 +191,10 @@
           input.copyAllScoresOnMergeFirstParentUpdate);
     }
 
+    if (input.copyValues != null) {
+      labelType.setCopyValues(input.copyValues);
+    }
+
     if (input.allowPostSubmit != null) {
       labelType.setAllowPostSubmit(input.allowPostSubmit);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 824b4ed..0a35865 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -205,6 +205,11 @@
       dirty = true;
     }
 
+    if (input.copyValues != null) {
+      labelType.setCopyValues(input.copyValues);
+      dirty = true;
+    }
+
     if (input.allowPostSubmit != null) {
       labelType.setAllowPostSubmit(input.allowPostSubmit);
       dirty = true;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 7e69251..923b66f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -185,6 +185,35 @@
   }
 
   @Test
+  public void stickyOnCopyValues() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getLabelSections()
+          .get("Code-Review")
+          .setCopyValues(ImmutableList.of((short) -1, (short) 1));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, -1, 1);
+      vote(user, changeId, -2, -1);
+      vote(user2, changeId, 1, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, -1, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+      assertVotes(c, user2, 1, 0, changeKind);
+    }
+  }
+
+  @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 57a1e56..e5587a9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -244,6 +244,7 @@
     assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
     assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(createdLabel.copyValues).isNull();
     assertThat(createdLabel.allowPostSubmit).isTrue();
     assertThat(createdLabel.ignoreSelfApproval).isNull();
   }
@@ -537,6 +538,17 @@
   }
 
   @Test
+  public void createWithCopyValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyValues = ImmutableList.of((short) -1, (short) 1);
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+  }
+
+  @Test
   public void createWithAllowPostSubmit() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 9f98490..940fae5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -117,6 +117,7 @@
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
     assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -134,6 +135,7 @@
       labelType.setCopyAllScoresIfNoCodeChange(true);
       labelType.setCopyAllScoresOnTrivialRebase(true);
       labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
       labelType.setIgnoreSelfApproval(true);
       u.getConfig().getLabelSections().put(labelType.getName(), labelType);
       u.save();
@@ -148,6 +150,7 @@
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
     assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
     assertThat(fooLabel.allowPostSubmit).isTrue();
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 7998ecb..65e352b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -47,6 +47,7 @@
     assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
     assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(codeReviewLabel.copyValues).isNull();
     assertThat(codeReviewLabel.allowPostSubmit).isTrue();
     assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index d2539e5..ef08079 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -139,6 +139,7 @@
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
     assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -156,6 +157,7 @@
       labelType.setCopyAllScoresIfNoCodeChange(true);
       labelType.setCopyAllScoresOnTrivialRebase(true);
       labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
       labelType.setIgnoreSelfApproval(true);
       u.getConfig().getLabelSections().put(labelType.getName(), labelType);
       u.save();
@@ -173,6 +175,7 @@
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
     assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
     assertThat(fooLabel.allowPostSubmit).isTrue();
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 97b795f..b08c72b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -771,6 +771,44 @@
   }
 
   @Test
+  public void setCopyValues() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyValues = ImmutableList.of((short) -1, (short) 1);
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues)
+        .containsExactly((short) -1, (short) 1)
+        .inOrder();
+  }
+
+  @Test
+  public void unsetCopyValues() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyValues = ImmutableList.of();
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyValues).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
+  }
+
+  @Test
   public void setAllowPostSubmit() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {