Merge "Fix warnings"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index cf89982..23bb0ef 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -951,6 +951,20 @@
can always edit or remove hashtags (even without having the `Edit Hashtags`
access right assigned).
+
+[[category_edit_custom_keyed_values]]
+=== Edit Custom Keyed Values
+
+This category permits users to add or remove
+custom keyed values on a change that is uploaded for review. Custom Keyed Values
+are used by plugins to store extra data. They are not surfaced in the UI, unless
+a plugin explicitly does so.
+
+The change owner and site administrators can always edit or remove custom
+keyed values (even without having the `Edit Custom Keyed Values` access right
+assigned).
+
+
[[example_roles]]
== Examples of typical roles in a project
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 6b5b934..d5d68b3f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2844,6 +2844,76 @@
]
----
+[[get-custom-keyed-values]]
+=== Get Custom Keyed Values
+--
+'GET /changes/link:#change-id[\{change-id\}]/custom-keyed-values'
+--
+
+Gets the custom keyed values associated with a change.
+
+.Request
+----
+ GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom-keyed-values HTTP/1.0
+----
+
+As response the change's custom keyed values are returned as a map of strings.
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "key1": "value1",
+ "key2": "value2"
+ }
+----
+
+[[set-custom-keyed-values]]
+=== Set Custom Keyed Values
+--
+'POST /changes/link:#change-id[\{change-id\}]/custom-keyed-values'
+--
+
+Adds and/or removes custom keyed values from a change.
+
+The custom keyed values to add or remove must be provided in the request body
+inside a link:#custom-keyed-values-input[CustomKeyedValuesInput] entity.
+
+.Request
+----
+ POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom-keyed-values HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "add" : {
+ "key1": "value1"
+ },
+ "remove" : [
+ "key2"
+ ]
+ }
+----
+
+As response the change's custom keyed values are returned as a map of strings to strings.
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "key1": "value1",
+ "key3": "value3"
+ }
+----
+
+
[[list-change-messages]]
=== List Change Messages
--
@@ -7182,6 +7252,8 @@
listeners that are implemented in plugins may. Please refer to the
documentation of the installed plugins to learn whether they support validation
options. Unknown validation options are silently ignored.
+|`custom_keyed_values`|optional|Custom keyed values as a
+map from custom keys to values.
|`merge` |optional|
The detail of a merge commit as a link:#merge-input[MergeInput] entity.
If set, the target branch (see `branch` field) must exist (it is not
@@ -7702,6 +7774,19 @@
=== ApplyProvidedFixInput
The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
+[[custom-keyed-values-input]]
+=== CustomKeyedValuesInput
+
+The `CustomKeyedValuesInput` entity contains information about custom keyed values
+to add to, and/or remove from, a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`add` |optional|The map of custom keyed values to be added to the change.
+|`remove` |optional|The list of custom keys to be removed from the change.
+|=======================
+
[options="header",cols="1,6"]
|=======================
|Field Name |Description
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 56fb748..77437b3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ChangeStatus;
@@ -492,6 +493,9 @@
/** References the source change and patchset that this change was cherry-picked from. */
@Nullable private PatchSet.Id cherryPickOf;
+ /** Custom keyed values that were provided during change creation. */
+ @Nullable private ImmutableMap<String, String> customKeyedValues;
+
Change() {}
public Change(
@@ -523,6 +527,7 @@
reviewStarted = other.reviewStarted;
revertOf = other.revertOf;
cherryPickOf = other.cherryPickOf;
+ customKeyedValues = other.customKeyedValues;
}
/** 32 bit integer identity for a change. */
@@ -713,6 +718,14 @@
this.cherryPickOf = cherryPickOf;
}
+ public void setCustomKeyedValues(ImmutableMap<String, String> customKeyedValues) {
+ this.customKeyedValues = customKeyedValues;
+ }
+
+ public ImmutableMap<String, String> getCustomKeyedValues() {
+ return customKeyedValues;
+ }
+
@Override
public String toString() {
return new StringBuilder(getClass().getSimpleName())
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 2a34579..0e959e7 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -36,6 +36,7 @@
public static final String DELETE = "delete";
public static final String DELETE_CHANGES = "deleteChanges";
public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+ public static final String EDIT_CUSTOM_KEYED_VALUES = "editCustomKeyedValues";
public static final String EDIT_HASHTAGS = "editHashtags";
public static final String EDIT_TOPIC_NAME = "editTopicName";
public static final String FORGE_AUTHOR = "forgeAuthor";
@@ -73,6 +74,7 @@
NAMES_LC.add(DELETE.toLowerCase(Locale.US));
NAMES_LC.add(DELETE_CHANGES.toLowerCase(Locale.US));
NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase(Locale.US));
+ NAMES_LC.add(EDIT_CUSTOM_KEYED_VALUES.toLowerCase(Locale.US));
NAMES_LC.add(EDIT_HASHTAGS.toLowerCase(Locale.US));
NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase(Locale.US));
NAMES_LC.add(FORGE_AUTHOR.toLowerCase(Locale.US));
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index ef61b68..d8fd727 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -15,6 +15,7 @@
package com.google.gerrit.extensions.api.changes;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ListChangesOption;
@@ -337,6 +338,16 @@
*/
Set<String> getHashtags() throws RestApiException;
+ /** Set custom keyed values on a change */
+ void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException;
+
+ /**
+ * Gets the custom keyed values on a change.
+ *
+ * @return customKeyedValues
+ */
+ ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException;
+
/**
* Manage the attention set.
*
@@ -720,6 +731,16 @@
}
@Override
+ public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public AttentionSetApi attention(String id) throws RestApiException {
throw new NotImplementedException();
}
diff --git a/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
new file mode 100644
index 0000000..a603328
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2023 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.extensions.api.changes;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class CustomKeyedValuesInput {
+ @DefaultInput public ImmutableMap<String, String> add;
+ public ImmutableSet<String> remove;
+
+ public CustomKeyedValuesInput() {}
+
+ public CustomKeyedValuesInput(ImmutableMap<String, String> add) {
+ this.add = add;
+ }
+
+ public CustomKeyedValuesInput(ImmutableMap<String, String> add, ImmutableSet<String> remove) {
+ this(add);
+ this.remove = remove;
+ }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 6f9cff7..2e2b9ca 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -38,6 +38,7 @@
public String baseCommit;
public Boolean newBranch;
public Map<String, String> validationOptions;
+ public Map<String, String> customKeyedValues;
public MergeInput merge;
public ApplyPatchInput patch;
diff --git a/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
new file mode 100644
index 0000000..d008675
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change's Custom Keyed Values are edited. */
+@ExtensionPoint
+public interface CustomKeyedValuesEditedListener {
+ interface Event extends ChangeEvent {
+ ImmutableMap<String, String> getCustomKeyedValues();
+
+ ImmutableMap<String, String> getAddedCustomKeyedValues();
+
+ ImmutableSet<String> getRemovedCustomKeys();
+ }
+
+ void onCustomKeyedValuesEdited(Event event);
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 4fba660..f5a4862 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ListMultimap;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -27,6 +28,7 @@
import com.google.gerrit.extensions.api.changes.ChangeEditApi;
import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -80,6 +82,7 @@
import com.google.gerrit.server.restapi.change.DeleteChange;
import com.google.gerrit.server.restapi.change.DeletePrivate;
import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.GetCustomKeyedValues;
import com.google.gerrit.server.restapi.change.GetHashtags;
import com.google.gerrit.server.restapi.change.GetMetaDiff;
import com.google.gerrit.server.restapi.change.GetPureRevert;
@@ -90,6 +93,7 @@
import com.google.gerrit.server.restapi.change.ListChangeRobotComments;
import com.google.gerrit.server.restapi.change.ListReviewers;
import com.google.gerrit.server.restapi.change.Move;
+import com.google.gerrit.server.restapi.change.PostCustomKeyedValues;
import com.google.gerrit.server.restapi.change.PostHashtags;
import com.google.gerrit.server.restapi.change.PostPrivate;
import com.google.gerrit.server.restapi.change.PostReviewers;
@@ -151,6 +155,8 @@
private final Provider<GetMetaDiff> getMetaDiffProvider;
private final PostHashtags postHashtags;
private final GetHashtags getHashtags;
+ private final PostCustomKeyedValues postCustomKeyedValues;
+ private final GetCustomKeyedValues getCustomKeyedValues;
private final AttentionSet attentionSet;
private final AttentionSetApiImpl.Factory attentionSetApi;
private final AddToAttentionSet addToAttentionSet;
@@ -201,6 +207,8 @@
Provider<GetMetaDiff> getMetaDiffProvider,
PostHashtags postHashtags,
GetHashtags getHashtags,
+ PostCustomKeyedValues postCustomKeyedValues,
+ GetCustomKeyedValues getCustomKeyedValues,
AttentionSet attentionSet,
AttentionSetApiImpl.Factory attentionSetApi,
AddToAttentionSet addToAttentionSet,
@@ -249,6 +257,8 @@
this.getMetaDiffProvider = getMetaDiffProvider;
this.postHashtags = postHashtags;
this.getHashtags = getHashtags;
+ this.postCustomKeyedValues = postCustomKeyedValues;
+ this.getCustomKeyedValues = getCustomKeyedValues;
this.attentionSet = attentionSet;
this.attentionSetApi = attentionSetApi;
this.addToAttentionSet = addToAttentionSet;
@@ -568,6 +578,24 @@
}
@Override
+ public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+ try {
+ postCustomKeyedValues.apply(change, input);
+ } catch (Exception e) {
+ throw asRestApiException("Cannot post custom keyed values", e);
+ }
+ }
+
+ @Override
+ public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+ try {
+ return getCustomKeyedValues.apply(change).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot get custom keyed values", e);
+ }
+ }
+
+ @Override
public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
try {
return addToAttentionSet.apply(change, input).value();
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 4273a72..83b7565 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -19,6 +19,7 @@
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Objects.requireNonNull;
@@ -26,6 +27,7 @@
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
@@ -138,6 +140,7 @@
private boolean workInProgress;
private List<String> groups = Collections.emptyList();
private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
+ private ImmutableMap<String, String> customKeyedValues = ImmutableMap.of();
private boolean validate = true;
private Map<String, Short> approvals;
private RequestScopePropagator requestScopePropagator;
@@ -220,6 +223,7 @@
change.setWorkInProgress(workInProgress);
change.setReviewStarted(!workInProgress);
change.setRevertOf(revertOf);
+ change.setCustomKeyedValues(customKeyedValues);
return change;
}
@@ -346,6 +350,13 @@
}
@CanIgnoreReturnValue
+ public ChangeInserter setCustomKeyedValues(ImmutableMap<String, String> customKeyedValues) {
+ requireNonNull(customKeyedValues, "customKeyedValues may not be null");
+ this.customKeyedValues = customKeyedValues;
+ return this;
+ }
+
+ @CanIgnoreReturnValue
public ChangeInserter setValidationOptions(
ImmutableListMultimap<String, String> validationOptions) {
requireNonNull(validationOptions, "validationOptions may not be null");
@@ -464,6 +475,18 @@
} catch (ValidationException ex) {
throw new BadRequestException(ex.getMessage());
}
+ if (change.getCustomKeyedValues() != null) {
+ try {
+ if (change.getCustomKeyedValues().entrySet().size() > MAX_CUSTOM_KEYED_VALUES) {
+ throw new ValidationException("Too many custom keyed values");
+ }
+ for (Map.Entry<String, String> entry : change.getCustomKeyedValues().entrySet()) {
+ update.addCustomKeyedValue(entry.getKey(), entry.getValue());
+ }
+ } catch (ValidationException ex) {
+ throw new BadRequestException(ex.getMessage());
+ }
+ }
update.setPsDescription(patchSetDescription);
update.setPrivate(isPrivate);
update.setWorkInProgress(workInProgress);
diff --git a/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
new file mode 100644
index 0000000..04bc6e4
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2023 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.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Map;
+
+public class CustomKeyedValuesUtil {
+ public static class InvalidCustomKeyedValueException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ static InvalidCustomKeyedValueException customKeyedValuesMayNotContainEquals() {
+ return new InvalidCustomKeyedValueException("custom keys may not contain equals sign");
+ }
+
+ static InvalidCustomKeyedValueException customKeyedValuesMayNotContainNewLine() {
+ return new InvalidCustomKeyedValueException("custom values may not contain newline");
+ }
+
+ InvalidCustomKeyedValueException(String message) {
+ super(message);
+ }
+ }
+
+ static ImmutableMap<String, String> extractCustomKeyedValues(ImmutableMap<String, String> input)
+ throws InvalidCustomKeyedValueException {
+ if (input == null) {
+ return ImmutableMap.of();
+ }
+ ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+ for (Map.Entry<String, String> customKeyedValue : input.entrySet()) {
+ if (customKeyedValue.getKey().contains("=")) {
+ throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainEquals();
+ }
+ if (customKeyedValue.getValue().contains("\n")) {
+ throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainNewLine();
+ }
+ String key = customKeyedValue.getKey().trim();
+ if (key.isEmpty()) {
+ continue;
+ }
+ builder.put(key, customKeyedValue.getValue());
+ }
+ return builder.build();
+ }
+
+ static ImmutableSet<String> extractCustomKeys(ImmutableSet<String> input)
+ throws InvalidCustomKeyedValueException {
+ if (input == null) {
+ return ImmutableSet.of();
+ }
+ ImmutableSet.Builder<String> builder = ImmutableSet.builder();
+ for (String customKey : input) {
+ if (customKey.contains("=")) {
+ throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainEquals();
+ }
+ String key = customKey.trim();
+ if (key.isEmpty()) {
+ continue;
+ }
+ builder.add(key);
+ }
+ return builder.build();
+ }
+
+ private CustomKeyedValuesUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java b/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java
new file mode 100644
index 0000000..0810c447
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2023 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.change.CustomKeyedValuesUtil.extractCustomKeyedValues;
+import static com.google.gerrit.server.change.CustomKeyedValuesUtil.extractCustomKeys;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.server.change.CustomKeyedValuesUtil.InvalidCustomKeyedValueException;
+import com.google.gerrit.server.extensions.events.CustomKeyedValuesEdited;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.validators.CustomKeyedValueValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class SetCustomKeyedValuesOp implements BatchUpdateOp {
+ public interface Factory {
+ SetCustomKeyedValuesOp create(CustomKeyedValuesInput input);
+ }
+
+ private final PluginSetContext<CustomKeyedValueValidationListener> validationListeners;
+ private final CustomKeyedValuesEdited customKeyedValuesEdited;
+ private final CustomKeyedValuesInput input;
+
+ private boolean fireEvent = true;
+
+ private Change change;
+ private ImmutableMap<String, String> toAdd;
+ private ImmutableSet<String> toRemove;
+ private ImmutableMap<String, String> updatedCustomKeyedValues;
+
+ @Inject
+ SetCustomKeyedValuesOp(
+ PluginSetContext<CustomKeyedValueValidationListener> validationListeners,
+ CustomKeyedValuesEdited customKeyedValuesEdited,
+ @Assisted @Nullable CustomKeyedValuesInput input) {
+ this.validationListeners = validationListeners;
+ this.customKeyedValuesEdited = customKeyedValuesEdited;
+ this.input = input;
+ }
+
+ public SetCustomKeyedValuesOp setFireEvent(boolean fireEvent) {
+ this.fireEvent = fireEvent;
+ return this;
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext ctx)
+ throws AuthException, BadRequestException, MethodNotAllowedException, IOException {
+ if (input == null || (input.add == null && input.remove == null)) {
+ updatedCustomKeyedValues = ImmutableMap.of();
+ return false;
+ }
+
+ change = ctx.getChange();
+ ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+ ChangeNotes notes = update.getNotes().load();
+
+ try {
+ ImmutableMap<String, String> existingCustomKeyedValues = notes.getCustomKeyedValues();
+ ImmutableMap<String, String> tryingToAdd = extractCustomKeyedValues(input.add);
+ ImmutableSet<String> tryingToRemove = extractCustomKeys(input.remove);
+
+ validationListeners.runEach(
+ l -> l.validateCustomKeyedValues(update.getChange(), tryingToAdd, tryingToRemove),
+ ValidationException.class);
+ Map<String, String> newValues = new HashMap<>(existingCustomKeyedValues);
+ Map<String, String> added = new HashMap<>();
+ // Do the removes before the additions so that adding a key with a value while
+ // removing the key consists of adding the key with that new value.
+ for (String key : tryingToRemove) {
+ if (!newValues.containsKey(key)) {
+ continue;
+ }
+ update.deleteCustomKeyedValue(key);
+ newValues.remove(key);
+ }
+ for (Map.Entry<String, String> add : tryingToAdd.entrySet()) {
+ if (newValues.containsKey(add.getKey())
+ && newValues.get(add.getKey()).equals(add.getValue())) {
+ continue;
+ }
+ update.addCustomKeyedValue(add.getKey(), add.getValue());
+ newValues.put(add.getKey(), add.getValue());
+ added.put(add.getKey(), add.getValue());
+ }
+ if (newValues.size() > MAX_CUSTOM_KEYED_VALUES) {
+ throw new ValidationException("Too many custom keyed values.");
+ }
+ toAdd = ImmutableMap.copyOf(added);
+ toRemove =
+ ImmutableSet.copyOf(
+ Sets.filter(tryingToRemove, k -> existingCustomKeyedValues.containsKey(k)));
+ updatedCustomKeyedValues = ImmutableMap.copyOf(newValues);
+ return true;
+ } catch (ValidationException | InvalidCustomKeyedValueException e) {
+ throw new BadRequestException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void postUpdate(PostUpdateContext ctx) {
+ if (updated() && fireEvent) {
+ customKeyedValuesEdited.fire(
+ ctx.getChangeData(change),
+ ctx.getAccount(),
+ updatedCustomKeyedValues,
+ toAdd,
+ toRemove,
+ ctx.getWhen());
+ }
+ }
+
+ public ImmutableMap<String, String> getUpdatedCustomKeyedValues() {
+ checkState(
+ updatedCustomKeyedValues != null,
+ "getUpdatedCustomKeyedValues() only valid after executing op");
+ return updatedCustomKeyedValues;
+ }
+
+ private boolean updated() {
+ return (toAdd != null && !toAdd.isEmpty()) || (toRemove != null && !toRemove.isEmpty());
+ }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index b823115..3373860 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -44,6 +44,7 @@
import com.google.gerrit.extensions.events.ChangeRestoredListener;
import com.google.gerrit.extensions.events.ChangeRevertedListener;
import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
import com.google.gerrit.extensions.events.GarbageCollectorListener;
import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -360,6 +361,7 @@
DynamicSet.setOf(binder(), ChangeDeletedListener.class);
DynamicSet.setOf(binder(), CommentAddedListener.class);
DynamicSet.setOf(binder(), HashtagsEditedListener.class);
+ DynamicSet.setOf(binder(), CustomKeyedValuesEditedListener.class);
DynamicSet.setOf(binder(), ChangeMergedListener.class);
bind(ChangeMergedListener.class)
.annotatedWith(Exports.named("CreateGroupPermissionSyncer"))
diff --git a/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java b/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java
new file mode 100644
index 0000000..353c830
--- /dev/null
+++ b/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 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.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+import java.util.Map;
+
+public class CustomKeyedValuesChangedEvent extends ChangeEvent {
+ static final String TYPE = "custom-keyed-values-changed";
+ public Supplier<AccountAttribute> editor;
+ public Map<String, String> added;
+ public String[] removed;
+ public Map<String, String> customKeyedValues;
+
+ public CustomKeyedValuesChangedEvent(Change change) {
+ super(TYPE, change);
+ }
+}
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index e24bbd2..2b35ee3 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -29,6 +29,7 @@
register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
+ register(CustomKeyedValuesChangedEvent.TYPE, CustomKeyedValuesChangedEvent.class);
register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
register(PrivateStateChangedEvent.TYPE, PrivateStateChangedEvent.class);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 50c15b7..2d90d9b 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -37,6 +37,7 @@
import com.google.gerrit.extensions.events.ChangeMergedListener;
import com.google.gerrit.extensions.events.ChangeRestoredListener;
import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
import com.google.gerrit.extensions.events.HashtagsEditedListener;
import com.google.gerrit.extensions.events.HeadUpdatedListener;
@@ -81,6 +82,7 @@
CommentAddedListener,
GitReferenceUpdatedListener,
HashtagsEditedListener,
+ CustomKeyedValuesEditedListener,
NewProjectCreatedListener,
ReviewerAddedListener,
ReviewerDeletedListener,
@@ -101,6 +103,8 @@
DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
.to(StreamEventsApiListener.class);
DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
+ DynamicSet.bind(binder(), CustomKeyedValuesEditedListener.class)
+ .to(StreamEventsApiListener.class);
DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
DynamicSet.bind(binder(), PrivateStateChangedListener.class)
.to(StreamEventsApiListener.class);
@@ -233,9 +237,9 @@
}
@Nullable
- String[] hashtagArray(Collection<String> hashtags) {
- if (hashtags != null && !hashtags.isEmpty()) {
- return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
+ String[] hashArray(Collection<String> collection) {
+ if (collection != null && !collection.isEmpty()) {
+ return Sets.newHashSet(collection).toArray(new String[collection.size()]);
}
return null;
}
@@ -342,9 +346,28 @@
event.change = changeAttributeSupplier(change, notes);
event.editor = accountAttributeSupplier(ev.getWho());
- event.hashtags = hashtagArray(ev.getHashtags());
- event.added = hashtagArray(ev.getAddedHashtags());
- event.removed = hashtagArray(ev.getRemovedHashtags());
+ event.hashtags = hashArray(ev.getHashtags());
+ event.added = hashArray(ev.getAddedHashtags());
+ event.removed = hashArray(ev.getRemovedHashtags());
+
+ dispatcher.run(d -> d.postEvent(change, event));
+ } catch (StorageException e) {
+ logger.atSevere().withCause(e).log("Failed to dispatch event");
+ }
+ }
+
+ @Override
+ public void onCustomKeyedValuesEdited(CustomKeyedValuesEditedListener.Event ev) {
+ try {
+ ChangeNotes notes = getNotes(ev.getChange());
+ Change change = notes.getChange();
+ CustomKeyedValuesChangedEvent event = new CustomKeyedValuesChangedEvent(change);
+
+ event.change = changeAttributeSupplier(change, notes);
+ event.editor = accountAttributeSupplier(ev.getWho());
+ event.customKeyedValues = ev.getCustomKeyedValues();
+ event.added = ev.getAddedCustomKeyedValues();
+ event.removed = hashArray(ev.getRemovedCustomKeys());
dispatcher.run(d -> d.postEvent(change, event));
} catch (StorageException e) {
diff --git a/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java b/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java
new file mode 100644
index 0000000..949840a
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2023 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.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+
+/** Helper class to fire an event when the hashtags of a change has been edited. */
+@Singleton
+public class CustomKeyedValuesEdited {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final PluginSetContext<CustomKeyedValuesEditedListener> listeners;
+ private final EventUtil util;
+
+ @Inject
+ public CustomKeyedValuesEdited(
+ PluginSetContext<CustomKeyedValuesEditedListener> listeners, EventUtil util) {
+ this.listeners = listeners;
+ this.util = util;
+ }
+
+ public void fire(
+ ChangeData changeData,
+ AccountState editor,
+ ImmutableMap<String, String> customKeyedValues,
+ ImmutableMap<String, String> added,
+ ImmutableSet<String> removed,
+ Instant when) {
+ if (listeners.isEmpty()) {
+ return;
+ }
+ try {
+ Event event =
+ new Event(
+ util.changeInfo(changeData),
+ util.accountInfo(editor),
+ customKeyedValues,
+ added,
+ removed,
+ when);
+ listeners.runEach(l -> l.onCustomKeyedValuesEdited(event));
+ } catch (StorageException e) {
+ logger.atSevere().withCause(e).log("Couldn't fire event");
+ }
+ }
+
+ /** Event to be fired when the custom keyed values of a change has been edited. */
+ private static class Event extends AbstractChangeEvent
+ implements CustomKeyedValuesEditedListener.Event {
+
+ private ImmutableMap<String, String> updated;
+ private ImmutableMap<String, String> added;
+ private ImmutableSet<String> removed;
+
+ Event(
+ ChangeInfo change,
+ AccountInfo editor,
+ ImmutableMap<String, String> updated,
+ ImmutableMap<String, String> added,
+ ImmutableSet<String> removed,
+ Instant when) {
+ super(change, editor, when, NotifyHandling.ALL);
+ this.updated = updated;
+ this.added = added;
+ this.removed = removed;
+ }
+
+ @Override
+ public ImmutableMap<String, String> getCustomKeyedValues() {
+ return updated;
+ }
+
+ @Override
+ public ImmutableMap<String, String> getAddedCustomKeyedValues() {
+ return added;
+ }
+
+ @Override
+ public ImmutableSet<String> getRemovedCustomKeys() {
+ return removed;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index be755ea..42588cf 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -140,6 +140,10 @@
ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator);
}
+ public static final int MAX_CUSTOM_KEY_LENGTH = 100;
+ public static final int MAX_CUSTOM_KEYED_VALUE_LENGTH = 1000;
+ public static final int MAX_CUSTOM_KEYED_VALUES = 100;
+
private final NoteDbUpdateManager.Factory updateManagerFactory;
private final ChangeDraftUpdate.Factory draftUpdateFactory;
private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
@@ -465,11 +469,20 @@
this.hashtags = hashtags;
}
- public void addCustomKeyedValue(String key, String value) {
+ public void addCustomKeyedValue(String key, String value) throws ValidationException {
+ if (key.length() > MAX_CUSTOM_KEY_LENGTH) {
+ throw new ValidationException("Custom Key is too long.");
+ }
+ if (value.length() > MAX_CUSTOM_KEYED_VALUE_LENGTH) {
+ throw new ValidationException("Custom Keyed value is too long.");
+ }
this.customKeyedValues.put(key, value);
}
- public void deleteCustomKeyedValue(String key) {
+ public void deleteCustomKeyedValue(String key) throws ValidationException {
+ if (key.length() > MAX_CUSTOM_KEY_LENGTH) {
+ throw new ValidationException("Custom Key is too long.");
+ }
this.customKeyedValues.put(key, "");
}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 88dbf87..2770f64 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -187,6 +187,12 @@
|| getProjectControl().isAdmin();
}
+ /** Can this user edit the custom keyed values? */
+ private boolean canEditCustomKeyedValues() {
+ return isOwner() // owner (aka creator) of the change can edit custom keyed values
+ || getProjectControl().isAdmin();
+ }
+
private boolean isPrivateVisible(ChangeData cd) {
if (isOwner()) {
logger.atFine().log(
@@ -291,6 +297,8 @@
return canEditDescription();
case EDIT_HASHTAGS:
return canEditHashtags();
+ case EDIT_CUSTOM_KEYED_VALUES:
+ return canEditCustomKeyedValues();
case EDIT_TOPIC_NAME:
return canEditTopicName();
case REBASE:
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 7741adac..d9f83c7 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -38,6 +38,7 @@
*/
ABANDON,
EDIT_DESCRIPTION,
+ EDIT_CUSTOM_KEYED_VALUES,
EDIT_HASHTAGS,
EDIT_TOPIC_NAME,
REMOVE_REVIEWER,
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 958de1b..1b87446 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -91,6 +91,7 @@
ImmutableBiMap.<ChangePermission, String>builder()
.put(ChangePermission.READ, Permission.READ)
.put(ChangePermission.ABANDON, Permission.ABANDON)
+ .put(ChangePermission.EDIT_CUSTOM_KEYED_VALUES, Permission.EDIT_CUSTOM_KEYED_VALUES)
.put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS)
.put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME)
.put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 46b85b7..01c2708 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -331,7 +331,7 @@
private SubmitTypeRecord submitTypeRecord;
private Boolean mergeable;
private Set<String> hashtags;
- private Map<String, String> customKeyedValues;
+ private ImmutableMap<String, String> customKeyedValues;
/**
* Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
* change and a given user.
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 33e6342..3e985c2 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -43,6 +43,7 @@
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.change.ReviewerResource;
import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
import com.google.gerrit.server.change.SetHashtagsOp;
import com.google.gerrit.server.change.SetPrivateOp;
import com.google.gerrit.server.change.SetTopicOp;
@@ -216,6 +217,7 @@
factory(RebaseChangeOp.Factory.class);
factory(ReviewerResource.Factory.class);
factory(SetCherryPickOp.Factory.class);
+ factory(SetCustomKeyedValuesOp.Factory.class);
factory(SetHashtagsOp.Factory.class);
factory(SetTopicOp.Factory.class);
factory(SetPrivateOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index a1bb987..4a70684 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -22,6 +22,7 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
@@ -447,6 +448,15 @@
ins.setValidationOptions(validationOptions.build());
}
+ if (input.customKeyedValues != null) {
+ ImmutableMap.Builder<String, String> customKeyedValues = ImmutableMap.builder();
+ input
+ .customKeyedValues
+ .entrySet()
+ .forEach(e -> customKeyedValues.put(e.getKey(), e.getValue()));
+ ins.setCustomKeyedValues(customKeyedValues.build());
+ }
+
try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
bu.setRepository(git, rw, oi);
bu.setNotify(
diff --git a/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java b/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java
new file mode 100644
index 0000000..47765ab
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2023 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.restapi.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class GetCustomKeyedValues implements RestReadView<ChangeResource> {
+ @Override
+ public Response<ImmutableMap<String, String>> apply(ChangeResource req)
+ throws AuthException, IOException, BadRequestException {
+ ChangeNotes notes = req.getNotes().load();
+ ImmutableMap<String, String> customKeyedValues = notes.getCustomKeyedValues();
+ if (customKeyedValues == null) {
+ customKeyedValues = ImmutableMap.of();
+ }
+ return Response.ok(customKeyedValues);
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java b/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java
new file mode 100644
index 0000000..d97107a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2023 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.restapi.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PostCustomKeyedValues
+ implements RestModifyView<ChangeResource, CustomKeyedValuesInput>, UiAction<ChangeResource> {
+ private final BatchUpdate.Factory updateFactory;
+ private final SetCustomKeyedValuesOp.Factory customKeyedValuesFactory;
+
+ @Inject
+ PostCustomKeyedValues(
+ BatchUpdate.Factory updateFactory, SetCustomKeyedValuesOp.Factory customKeyedValuesFactory) {
+ this.updateFactory = updateFactory;
+ this.customKeyedValuesFactory = customKeyedValuesFactory;
+ }
+
+ @Override
+ public Response<ImmutableMap<String, String>> apply(
+ ChangeResource req, CustomKeyedValuesInput input)
+ throws RestApiException, UpdateException, PermissionBackendException {
+ req.permissions().check(ChangePermission.EDIT_CUSTOM_KEYED_VALUES);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+ SetCustomKeyedValuesOp op = customKeyedValuesFactory.create(input);
+ bu.addOp(req.getId(), op);
+ bu.execute();
+ return Response.ok(op.getUpdatedCustomKeyedValues());
+ }
+ }
+ }
+
+ @Override
+ public UiAction.Description getDescription(ChangeResource rsrc) {
+ return new UiAction.Description()
+ .setLabel("Edit custom keyed values")
+ .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_CUSTOM_KEYED_VALUES));
+ }
+}
diff --git a/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java b/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java
new file mode 100644
index 0000000..f13330d
--- /dev/null
+++ b/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 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.validators;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Listener to provide validation of custom keyed values changes. */
+@ExtensionPoint
+public interface CustomKeyedValueValidationListener {
+ /**
+ * Invoked by Gerrit before custom keyed values are changed.
+ *
+ * @param change the change on which the custom keyed values are changed
+ * @param toAdd the custom keyed values to be added
+ * @param toRemove the custom keys to be removed
+ * @throws ValidationException if validation fails
+ */
+ void validateCustomKeyedValues(
+ Change change, ImmutableMap<String, String> toAdd, ImmutableSet<String> toRemove)
+ throws ValidationException;
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index eaee806..cbc3f9d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -52,6 +52,7 @@
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
@@ -689,9 +690,9 @@
}
@Test
- public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
+ public void reviewersAreNotAddedForNoReasonBecauseOfAHashtagUpdate() throws Exception {
PushOneCommit.Result r = createChange();
- // implictly adds the user to the attention set when adding as reviewer
+ // implicitly adds the user to the attention set when adding as reviewer
change(r).addReviewer(user.email());
change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
@@ -707,6 +708,24 @@
}
@Test
+ public void reviewersAreNotAddedForNoReasonBecauseOfACustomKeyedValuesUpdate() throws Exception {
+ PushOneCommit.Result r = createChange();
+ // implicitly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
+ change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+
+ CustomKeyedValuesInput customKeyedValuesInput = new CustomKeyedValuesInput();
+ customKeyedValuesInput.add = ImmutableMap.of("key1", "value1");
+ change(r).setCustomKeyedValues(customKeyedValuesInput);
+
+ AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
+ }
+
+ @Test
public void reviewAddsManuallyAddedUserToAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 1952b32..0550cb9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -1296,6 +1296,19 @@
}
}
+ @Test
+ public void createChangeWithCustomKeyedValues() throws Exception {
+ ChangeInput changeInput = new ChangeInput();
+ changeInput.project = project.get();
+ changeInput.branch = "master";
+ changeInput.subject = "A change";
+ changeInput.status = ChangeStatus.NEW;
+ changeInput.customKeyedValues = ImmutableMap.of("key", "value");
+
+ ChangeInfo result = assertCreateSucceeds(changeInput);
+ assertThat(result.customKeyedValues).containsExactly("key", "value");
+ }
+
private ChangeInput newChangeInput(ChangeStatus status) {
ChangeInput in = new ChangeInput();
in.project = project.get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
new file mode 100644
index 0000000..03722e6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2023 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.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUE_LENGTH;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEY_LENGTH;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.truth.MapSubject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+@UseClockStep
+public class CustomKeyedValuesIT extends AbstractDaemonTest {
+ @Inject private RequestScopeOperations requestScopeOperations;
+
+ @Test
+ public void getNoCustomKeyedValues() throws Exception {
+ // Get on a change with no hashtags returns an empty list.
+ PushOneCommit.Result r = createChange();
+ assertThatGet(r).isEmpty();
+ }
+
+ @Test
+ public void addSingleCustomKeyedValue() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+ assertThatGet(r).containsExactly("key1", "value1");
+ assertNoNewMessageSince(r, last);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key2", "value2"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void addInvalidCustomKeyedValue() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () -> addCustomKeyedValues(r, ImmutableMap.of("key=", "value")));
+ assertThat(thrown).hasMessageThat().contains("custom keys may not contain equals");
+ }
+
+ @Test
+ public void addMultipleCustomKeyedValues() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key3", "value3"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void addAlreadyExistingCustomKeyedValue() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+ assertThatGet(r).containsExactly("key1", "value1");
+ assertNoNewMessageSince(r, last);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value2"));
+ assertThatGet(r).containsExactly("key1", "value2");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void removeSingleCustomKeyedValue() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+ assertThatGet(r).containsExactly("key1", "value1");
+ assertNoNewMessageSince(r, last);
+
+ removeCustomKeys(r, ImmutableSet.of("key1"));
+ assertThatGet(r).containsExactly();
+ assertNoNewMessageSince(r, last);
+
+ // Removing a single custom keyed value returns the other custom keyed values.
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+
+ removeCustomKeys(r, ImmutableSet.of("key1"));
+ assertThatGet(r).containsExactly("key2", "value2");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void removeMultipleCustomKeys() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+ removeCustomKeys(r, ImmutableSet.of("key1", "key2"));
+ assertThatGet(r).containsExactly();
+ assertNoNewMessageSince(r, last);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+ assertNoNewMessageSince(r, last);
+ removeCustomKeys(r, ImmutableSet.of("key1", "key2"));
+ assertThatGet(r).containsExactly("key3", "value3");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void removeNotExistingCustomKey() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+ removeCustomKeys(r, ImmutableSet.of("key1"));
+ assertThatGet(r).isEmpty();
+ assertNoNewMessageSince(r, last);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+ assertThatGet(r).containsExactly("key1", "value1");
+ assertNoNewMessageSince(r, last);
+ removeCustomKeys(r, ImmutableSet.of("key2"));
+ assertThatGet(r).containsExactly("key1", "value1");
+ assertNoNewMessageSince(r, last);
+
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+ assertNoNewMessageSince(r, last);
+ removeCustomKeys(r, ImmutableSet.of("key4"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void addAndRemove() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ChangeMessageInfo last = getLastMessage(r);
+ addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+ assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+
+ // Adding and removing the same key updates it
+ CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+ input.add = ImmutableMap.of("key1", "value3");
+ input.remove = ImmutableSet.of("key1");
+ change(r).setCustomKeyedValues(input);
+ assertThatGet(r).containsExactly("key1", "value3", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+
+ // Adding and removing same key with same value is a no-op.
+ input = new CustomKeyedValuesInput();
+ input.add = ImmutableMap.of("key1", "value3");
+ input.remove = ImmutableSet.of("key1");
+ change(r).setCustomKeyedValues(input);
+ assertThatGet(r).containsExactly("key1", "value3", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+
+ // Adding and removing separate keys should work as expected.
+ input = new CustomKeyedValuesInput();
+ input.add = ImmutableMap.of("key4", "value4");
+ input.remove = ImmutableSet.of("key1");
+ change(r).setCustomKeyedValues(input);
+ assertThatGet(r).containsExactly("key4", "value4", "key2", "value2");
+ assertNoNewMessageSince(r, last);
+ }
+
+ @Test
+ public void addCustomKeyedValuesWithoutPermissionNotAllowed() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ AuthException thrown =
+ assertThrows(
+ AuthException.class, () -> addCustomKeyedValues(r, ImmutableMap.of("key1", "value1")));
+ assertThat(thrown).hasMessageThat().contains("edit custom keyed values not permitted");
+ }
+
+ @Test
+ public void addCustomKeyedValueKeyTooLongNotAllowed() throws Exception {
+ PushOneCommit.Result r = createChange();
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ addCustomKeyedValues(
+ r, ImmutableMap.of("k".repeat(MAX_CUSTOM_KEY_LENGTH + 1), "value1")));
+ assertThat(thrown).hasMessageThat().contains("Custom Key is too long.");
+ }
+
+ @Test
+ public void addCustomKeyedValueValueTooLongNotAllowed() throws Exception {
+ PushOneCommit.Result r = createChange();
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ addCustomKeyedValues(
+ r, ImmutableMap.of("key1", "v".repeat(MAX_CUSTOM_KEYED_VALUE_LENGTH + 1))));
+ assertThat(thrown).hasMessageThat().contains("Custom Keyed value is too long.");
+ }
+
+ @Test
+ public void addCustomKeyedValueTooManyKeyedValuesNotAllowed() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ImmutableMap.Builder<String, String> input = ImmutableMap.builder();
+ for (int i = 0; i <= MAX_CUSTOM_KEYED_VALUES; i++) {
+ input.put("key" + i, "value" + i);
+ }
+ BadRequestException thrown =
+ assertThrows(BadRequestException.class, () -> addCustomKeyedValues(r, input.build()));
+ assertThat(thrown).hasMessageThat().contains("Too many custom keyed values.");
+ }
+
+ private MapSubject assertThatGet(PushOneCommit.Result r) throws Exception {
+ return assertThat(change(r).getCustomKeyedValues());
+ }
+
+ private void addCustomKeyedValues(PushOneCommit.Result r, ImmutableMap<String, String> toAdd)
+ throws Exception {
+ CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+ input.add = toAdd;
+ change(r).setCustomKeyedValues(input);
+ }
+
+ private void removeCustomKeys(PushOneCommit.Result r, ImmutableSet<String> toRemove)
+ throws Exception {
+ CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+ input.remove = toRemove;
+ change(r).setCustomKeyedValues(input);
+ }
+
+ private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
+ throws Exception {
+ requireNonNull(expected);
+ ChangeMessageInfo last = getLastMessage(r);
+ assertThat(last.message).isEqualTo(expected.message);
+ assertThat(last.id).isEqualTo(expected.id);
+ }
+
+ private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
+ ChangeMessageInfo lastMessage = Iterables.getLast(change(r).get().messages, null);
+ assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
+ return lastMessage;
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bbf10bd..296d801 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,6 +26,7 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.proto.Entities;
import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
import java.lang.reflect.Type;
import java.time.Instant;
import org.junit.Test;
@@ -284,6 +285,9 @@
.put("currentPatchSetId", int.class)
.put("subject", String.class)
.put("topic", String.class)
+ .put(
+ "customKeyedValues",
+ new TypeLiteral<ImmutableMap<String, String>>() {}.getType())
.put("originalSubject", String.class)
.put("submissionId", String.class)
.put("isPrivate", boolean.class)
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 390aa84..0aaa437 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -18,6 +18,7 @@
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
@@ -103,6 +104,26 @@
}
@Test
+ public void customKeyedValuesChangedEvent() {
+ Change change = newChange();
+ CustomKeyedValuesChangedEvent orig = new CustomKeyedValuesChangedEvent(change);
+ orig.change = asChangeAttribute(change);
+ orig.editor = newAccount("editor");
+ orig.added = ImmutableMap.of("key1", "value1");
+ orig.removed = new String[] {"removed"};
+ orig.customKeyedValues = ImmutableMap.of("key2", "value2");
+
+ CustomKeyedValuesChangedEvent e = roundTrip(orig);
+
+ assertThat(e).isNotNull();
+ assertSameChangeEvent(e, orig);
+ assertSameAccount(e.editor, orig.editor);
+ assertThat(e.added).isEqualTo(orig.added);
+ assertThat(e.removed).isEqualTo(orig.removed);
+ assertThat(e.customKeyedValues).isEqualTo(orig.customKeyedValues);
+ }
+
+ @Test
public void changeAbandonedEvent() {
Change change = newChange();
ChangeAbandonedEvent orig = new ChangeAbandonedEvent(change);
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 244002e..5b6019e 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -367,6 +367,7 @@
topic?: TopicName;
attention_set?: IdToAttentionSetMap;
hashtags?: Hashtag[];
+ custom_keyed_values?: CustomKeyedValues;
change_id: ChangeId;
subject: string;
status: ChangeStatus;
@@ -732,6 +733,12 @@
export type Hashtag = BrandType<string, '_hashtag'>;
+export type CustomKey = BrandType<string, '_custom_key'>;
+export type CustomValue = BrandType<string, '_custom_value'>;
+
+// A map from CustomKey to CustomValue
+export type CustomKeyedValues = {[key: CustomKey]: CustomValue};
+
export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
/**
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index b148780..7a6610b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -52,6 +52,8 @@
ConfigParameterInfo,
ConfigParameterInfoBase,
ContributorAgreementInfo,
+ CustomKey,
+ CustomKeyedValues,
DetailedLabelInfo,
DownloadInfo,
DownloadSchemeInfo,
@@ -1112,6 +1114,15 @@
}
/**
+ * The CustomKeyedValuesInput entity contains information about hashtags to add to, and/or remove from, a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#custom-keyed-values-input
+ */
+export interface CustomKeyedValuesInput {
+ add?: CustomKeyedValues;
+ remove?: CustomKey[];
+}
+
+/**
* The HashtagsInput entity contains information about hashtags to add to, and/or remove from, a change
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
*/
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 6fa4d0f..e8bd3d8 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -29,7 +29,7 @@
"test:browsers": "web-test-runner --playwright --browsers webkit firefox chromium",
"test:coverage": "web-test-runner --coverage",
"test:watch": "web-test-runner --watch",
- "test:single": "web-test-runner --watch --files",
+ "test:single": "web-test-runner --watch --group default --files",
"test:single:coverage": "web-test-runner --watch --coverage --files"
},
"license": "Apache-2.0",