Merge "Change test:single to only run the default group"
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
  */