Submit requirements: Add support for querying labels with MAX/MIN/ANY
This change adds support for creating different submit requirement
expressions using MAX, MIN or ANY for label values, the syntax works
like the following:
submittability_expression = label:code-review=MAX
This would allow us to express the current MaxWithBlock function as
submittability_expression =
label:code-review=MAX AND -label:code-review=MIN
This change adds this support for the change query language as well,
since the change query language is used by submit requirements.
Change-Id: I290f3f699291f324b760291daa3b27a718344803
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index a2dc31f..a9779b1 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -702,6 +702,18 @@
to one of the fields in the
link:rest-api-changes.html#submit-record[SubmitRecord] REST API entity.
+`label:Code-Review=MAX`::
++
+Matches changes with label voted with the highest possible score.
+
+`label:Code-Review=MIN`::
++
+Matches changes with label voted with the lowest possible score.
+
+`label:Code-Review=ANY`::
++
+Matches changes with label voted with any score.
+
`label:Non-Author-Code-Review=need`::
+
Matches changes where the submit rules indicate that a label named
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 9c39c6e..810cd4d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -43,11 +43,13 @@
import com.google.common.flogger.FluentLogger;
import com.google.common.io.Files;
import com.google.common.primitives.Longs;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LegacySubmitRequirement;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
@@ -72,6 +74,7 @@
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.MagicLabelValue;
import com.google.gson.Gson;
import com.google.protobuf.MessageLite;
import java.sql.Timestamp;
@@ -602,16 +605,35 @@
for (PatchSetApproval a : cd.currentApprovals()) {
if (a.value() != 0 && !a.isLegacySubmit()) {
allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+ LabelType labelType = cd.getLabelTypes().byLabel(a.labelId());
+ allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
if (owners && cd.change().getOwner().equals(a.accountId())) {
allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+ allApprovals.addAll(
+ getMaxMinAnyLabels(
+ a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
}
distinctApprovals.add(formatLabel(a.label(), a.value()));
+ distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
}
}
allApprovals.addAll(distinctApprovals);
return allApprovals;
}
+ private static List<String> getMaxMinAnyLabels(
+ String label, short labelVal, LabelType labelType, @Nullable Account.Id accountId) {
+ List<String> labels = new ArrayList<>();
+ if (labelVal == labelType.getMaxPositive()) {
+ labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+ }
+ if (labelVal == labelType.getMaxNegative()) {
+ labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+ }
+ labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+ return labels;
+ }
+
public static Set<String> getAuthorParts(ChangeData cd) {
return SchemaUtil.getPersonParts(cd.getAuthor());
}
@@ -696,6 +718,17 @@
+ (accountId != null ? "," + formatAccount(accountId) : "");
}
+ public static String formatLabel(String label, String value) {
+ return formatLabel(label, value, null);
+ }
+
+ public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+ return label.toLowerCase()
+ + "="
+ + value
+ + (accountId != null ? "," + formatAccount(accountId) : "");
+ }
+
private static String formatAccount(Account.Id accountId) {
if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
return ChangeQueryBuilder.ARG_ID_OWNER;
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 6355674..879da4f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -150,6 +150,9 @@
*/
static final Schema<ChangeData> V63 = schema(V62, false);
+ /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
+ static final Schema<ChangeData> V64 = schema(V63, false);
+
/**
* Name of the change index to be used when contacting index backends or loading configurations.
*/
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 94b5442..131de74 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -973,7 +973,7 @@
int eq = name.indexOf('=');
if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
String statusName = name.substring(eq + 1).toUpperCase();
- if (!isInt(statusName)) {
+ if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
SubmitRecord.Label.Status status =
Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
if (status == null) {
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 38d1dbe..989b4bb 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.query.change;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -59,12 +60,12 @@
protected static class Parsed {
protected final String label;
protected final String test;
- protected final int expVal;
+ protected final int numericValue;
- protected Parsed(String label, String test, int expVal) {
+ protected Parsed(String label, String test, int numericValue) {
this.label = label;
this.test = test;
- this.expVal = expVal;
+ this.numericValue = numericValue;
}
}
@@ -83,6 +84,14 @@
protected static List<Predicate<ChangeData>> predicates(Args args) {
String v = args.value;
+
+ try {
+ MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
+ return ImmutableList.of(new MagicLabelPredicate(args, mlv));
+ } catch (IllegalArgumentException e) {
+ // Try next format.
+ }
+
Parsed parsed = null;
try {
@@ -108,7 +117,7 @@
} else {
range =
RangeUtil.getRange(
- parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
+ parsed.label, parsed.test, parsed.numericValue, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
}
String prefix = range.prefix;
int min = range.min;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
new file mode 100644
index 0000000..e3c58e47
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class MagicLabelPredicate extends ChangeIndexPredicate {
+ protected final LabelPredicate.Args args;
+ private final MagicLabelVote magicLabelVote;
+
+ public MagicLabelPredicate(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
+ super(ChangeField.LABEL, magicLabelVote.formatLabel());
+ this.args = args;
+ this.magicLabelVote = magicLabelVote;
+ }
+
+ @Override
+ public boolean match(ChangeData changeData) {
+ Change change = changeData.change();
+ if (change == null) {
+ // The change has disappeared.
+ //
+ return false;
+ }
+
+ Optional<ProjectState> project = args.projectCache.get(change.getDest().project());
+ if (!project.isPresent()) {
+ // The project has disappeared.
+ //
+ return false;
+ }
+
+ LabelType labelType = type(project.get().getLabelTypes(), magicLabelVote.label());
+ if (labelType == null) {
+ return false; // Label is not defined by this project.
+ }
+
+ switch (magicLabelVote.value()) {
+ case ANY:
+ return matchAny(changeData, labelType);
+ case MIN:
+ return matchNumeric(changeData, magicLabelVote.label(), labelType.getMin().getValue());
+ case MAX:
+ return matchNumeric(changeData, magicLabelVote.label(), labelType.getMax().getValue());
+ }
+
+ throw new IllegalStateException("Unsupported magic label value: " + magicLabelVote.value());
+ }
+
+ private boolean matchAny(ChangeData changeData, LabelType labelType) {
+ List<Predicate<ChangeData>> predicates = new ArrayList<>();
+ for (LabelValue labelValue : labelType.getValues()) {
+ if (labelValue.getValue() != 0) {
+ predicates.add(numericPredicate(labelType.getName(), labelValue.getValue()));
+ }
+ }
+ return or(predicates).asMatchable().match(changeData);
+ }
+
+ private boolean matchNumeric(ChangeData changeData, String label, short value) {
+ return numericPredicate(label, value).match(changeData);
+ }
+
+ private EqualsLabelPredicate numericPredicate(String label, short value) {
+ return new EqualsLabelPredicate(args, label, value, /* account= */ null);
+ }
+
+ protected static LabelType type(LabelTypes types, String toFind) {
+ if (types.byLabel(toFind) != null) {
+ return types.byLabel(toFind);
+ }
+
+ for (LabelType lt : types.getLabelTypes()) {
+ if (toFind.equalsIgnoreCase(lt.getName())) {
+ return lt;
+ }
+ }
+ return null;
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelValue.java b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
new file mode 100644
index 0000000..c4bcbe3
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import java.util.Optional;
+
+public enum MagicLabelValue {
+ ANY,
+ MIN,
+ MAX;
+
+ public static Optional<MagicLabelValue> tryParse(String value) {
+ try {
+ return Optional.of(MagicLabelValue.valueOf(value));
+ } catch (IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelVote.java b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
new file mode 100644
index 0000000..c29ac72
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.LabelType;
+import java.util.Locale;
+
+/** An entity representing a special label vote that's not numeric, e.g. MAX, MIN, etc... */
+@AutoValue
+public abstract class MagicLabelVote {
+ public static MagicLabelVote parseWithEquals(String text) {
+ checkArgument(!Strings.isNullOrEmpty(text), "Empty label vote");
+ int e = text.lastIndexOf('=');
+ checkArgument(e >= 0, "Label vote missing '=': %s", text);
+ String label = text.substring(0, e);
+ String voteValue = text.substring(e + 1);
+ return create(label, MagicLabelValue.valueOf(voteValue));
+ }
+
+ public static MagicLabelVote create(String label, MagicLabelValue value) {
+ return new AutoValue_MagicLabelVote(LabelType.checkNameInternal(label), value);
+ }
+
+ public abstract String label();
+
+ public abstract MagicLabelValue value();
+
+ public String formatLabel() {
+ return label().toLowerCase(Locale.US) + "=" + value().name();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 42354ca..976e828 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4020,6 +4020,86 @@
}
@Test
+ public void submitRequirement_withLabelEqualsMax() throws Exception {
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName("code-review")
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("label:code-review=MAX"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+ voteLabel(changeId, "code-review", 2);
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+ }
+
+ @Test
+ public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName("code-review")
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("-label:code-review=MIN"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ // Requirement is satisfied because there are no votes
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+ voteLabel(changeId, "code-review", -1);
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ // Requirement is still satisfied because -1 is not the max negative value
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+ voteLabel(changeId, "code-review", -2);
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ // Requirement is now unsatisfied because -2 is the max negative value
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+ }
+
+ @Test
+ public void submitRequirement_withLabelEqualsAny() throws Exception {
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName("code-review")
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("label:code-review=ANY"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+ voteLabel(changeId, "code-review", 1);
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+ }
+
+ @Test
public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
throws Exception {
configSubmitRequirement(
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3c10fbc..1f29f45 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1007,6 +1007,7 @@
changes.put(-1, reviewMinus1Change);
changes.put(-2, reviewMinus2Change);
+ assertQuery("label:Code-Review=MIN", reviewMinus2Change);
assertQuery("label:Code-Review=-2", reviewMinus2Change);
assertQuery("label:Code-Review-2", reviewMinus2Change);
assertQuery("label:Code-Review=-1", reviewMinus1Change);
@@ -1018,6 +1019,13 @@
assertQuery("label:Code-Review=+2", reviewPlus2Change);
assertQuery("label:Code-Review=2", reviewPlus2Change);
assertQuery("label:Code-Review+2", reviewPlus2Change);
+ assertQuery("label:Code-Review=MAX", reviewPlus2Change);
+ assertQuery(
+ "label:Code-Review=ANY",
+ reviewPlus2Change,
+ reviewPlus1Change,
+ reviewMinus1Change,
+ reviewMinus2Change);
assertQuery("label:Code-Review>-3", codeReviewInRange(changes, -2, 2));
assertQuery("label:Code-Review>=-2", codeReviewInRange(changes, -2, 2));