Merge "Add REST endpoint to batch update label definitions"
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 13dcf64..c373b96 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3233,6 +3233,66 @@
   HTTP/1.1 204 No Content
 ----
 
+[[batch-update-labels]]
+=== Batch Update Labels
+--
+'POST /projects/link:#project-name[\{project-name\}]/labels/'
+--
+
+Creates/updates/deletes multiple label definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-label-input[BatchLabelInput] entity.
+
+The updates are processed in the following order:
+
+1. label deletions
+2. label creations
+3. label updates
+
+.Request
+----
+  POST /projects/My-Project/labels/ HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Labels",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ],
+    "create": [
+      {
+        "name": "Foo-Review",
+        "values": {
+          " 0": "No score",
+          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be merged",
+          "+1": "Looks good to me, but someone else must approve",
+          "+2": "Looks good to me, approved"
+      }
+    ],
+    "update:" {
+      "Bar-Review": {
+        "function": "MaxWithBlock"
+      },
+      "Baz-Review": {
+        "copy_min_score": true
+      }
+    }
+  }
+----
+
+If the label updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
 
 [[ids]]
 == IDs
@@ -3874,9 +3934,14 @@
 |Field Name      ||Description
 |`commit_message`|optional|
 Message that should be used to commit the change of the label in the
-`project.config` file to the `refs/meta/config` branch.
+`project.config` file to the `refs/meta/config` branch.+
+Must not be set if this `LabelDefinitionInput` entity is contained in a
+link:#batch-label-input[BatchLabelInput] entity.
 |`name`          |optional|
-The new link:config-labels.html#label_name[name] of the label.
+The new link:config-labels.html#label_name[name] of the label.+
+For label creation the name is required if this `LabelDefinitionInput` entity
+is contained in a link:#batch-label-input[BatchLabelInput]
+entity.
 |`function`      |optional|
 The new link:config-labels.html#label_function[function] of the label (can be
 `MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
@@ -3959,6 +4024,27 @@
 Not set if not inherited or overridden.
 |===============================
 
+[[batch-label-input]]
+=== BatchLabelInput
+The `BatchLabelInput` entity contains information for batch updating label
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the label updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete`        |optional|
+List of labels that should be deleted.
+|`create`        |optional|
+List of link:#label-definition-input[LabelDefinitionInput] entities that
+describe labels that should be created.
+|`update`        |optional|
+Map of label names to link:#label-definition-input[LabelDefinitionInput]
+entities that describe the updates that should be done for the labels.
+|=============================
+
 [[project-access-input]]
 === ProjectAccessInput
 The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 6d02ec4..9873995 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -221,6 +222,13 @@
   LabelApi label(String labelName) throws RestApiException;
 
   /**
+   * Adds, updates and deletes label definitions in a batch.
+   *
+   * @param input input that describes additions, updates and deletions of label definitions
+   */
+  void labels(BatchLabelInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -404,5 +412,10 @@
     public LabelApi label(String labelName) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void labels(BatchLabelInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/BatchLabelInput.java b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
new file mode 100644
index 0000000..eb4c581
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 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.common;
+
+import java.util.List;
+import java.util.Map;
+
+/** Input for the REST API that describes additions, updates and deletions of label definitions. */
+public class BatchLabelInput {
+  public String commitMessage;
+  public List<String> delete;
+  public List<LabelDefinitionInput> create;
+  public Map<String, LabelDefinitionInput> update;
+}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index d7ab91b..6d7fc15 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -77,6 +78,7 @@
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListLabels;
 import com.google.gerrit.server.restapi.project.ListTags;
+import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
 import com.google.gerrit.server.restapi.project.PutDescription;
@@ -131,6 +133,7 @@
   private final Index index;
   private final IndexChanges indexChanges;
   private final Provider<ListLabels> listLabels;
+  private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
 
   @AssistedInject
@@ -168,6 +171,7 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
       @Assisted ProjectResource project) {
     this(
@@ -205,6 +209,7 @@
         index,
         indexChanges,
         listLabels,
+        postLabels,
         labelApi,
         null);
   }
@@ -244,6 +249,7 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
       @Assisted String name) {
     this(
@@ -281,6 +287,7 @@
         index,
         indexChanges,
         listLabels,
+        postLabels,
         labelApi,
         name);
   }
@@ -320,6 +327,7 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
       String name) {
     this.permissionBackend = permissionBackend;
@@ -357,6 +365,7 @@
     this.index = index;
     this.indexChanges = indexChanges;
     this.listLabels = listLabels;
+    this.postLabels = postLabels;
     this.labelApi = labelApi;
   }
 
@@ -712,4 +721,13 @@
       throw asRestApiException("Cannot parse label", e);
     }
   }
+
+  @Override
+  public void labels(BatchLabelInput input) throws RestApiException {
+    try {
+      postLabels.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update labels", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 03b9452..5d51527 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -91,85 +91,7 @@
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
 
-      if (config.getLabelSections().containsKey(id.get())) {
-        throw new ResourceConflictException(String.format("label %s already exists", id.get()));
-      }
-
-      for (String labelName : config.getLabelSections().keySet()) {
-        if (labelName.equalsIgnoreCase(id.get())) {
-          throw new ResourceConflictException(
-              String.format("label %s conflicts with existing label %s", id.get(), labelName));
-        }
-      }
-
-      if (input.values == null || input.values.isEmpty()) {
-        throw new BadRequestException("values are required");
-      }
-
-      List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
-
-      LabelType labelType;
-      try {
-        labelType = new LabelType(id.get(), values);
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException("invalid name: " + id.get(), e);
-      }
-
-      if (input.function != null && !input.function.trim().isEmpty()) {
-        labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
-      } else {
-        labelType.setFunction(LabelFunction.MAX_WITH_BLOCK);
-      }
-
-      if (input.defaultValue != null) {
-        labelType.setDefaultValue(
-            LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
-      }
-
-      if (input.branches != null) {
-        labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
-      }
-
-      if (input.canOverride != null) {
-        labelType.setCanOverride(input.canOverride);
-      }
-
-      if (input.copyAnyScore != null) {
-        labelType.setCopyAnyScore(input.copyAnyScore);
-      }
-
-      if (input.copyMinScore != null) {
-        labelType.setCopyMinScore(input.copyMinScore);
-      }
-
-      if (input.copyMaxScore != null) {
-        labelType.setCopyMaxScore(input.copyMaxScore);
-      }
-
-      if (input.copyAllScoresIfNoChange != null) {
-        labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      }
-
-      if (input.copyAllScoresIfNoCodeChange != null) {
-        labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-      }
-
-      if (input.copyAllScoresOnTrivialRebase != null) {
-        labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-      }
-
-      if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-        labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-            input.copyAllScoresOnMergeFirstParentUpdate);
-      }
-
-      if (input.allowPostSubmit != null) {
-        labelType.setAllowPostSubmit(input.allowPostSubmit);
-      }
-
-      if (input.ignoreSelfApproval != null) {
-        labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
-      }
+      LabelType labelType = createLabel(config, id.get(), input);
 
       if (input.commitMessage != null) {
         md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
@@ -177,7 +99,6 @@
         md.setMessage("Update label");
       }
 
-      config.getLabelSections().put(labelType.getName(), labelType);
       config.commit(md);
 
       projectCache.evict(rsrc.getProjectState().getProject());
@@ -185,4 +106,101 @@
       return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
     }
   }
+
+  /**
+   * Creates a new label.
+   *
+   * @param config the project config
+   * @param label the name of the new label
+   * @param input the input that describes the new label
+   * @return the created label type
+   * @throws BadRequestException if there was invalid data in the input
+   * @throws ResourceConflictException if the label cannot be created due to a conflict
+   */
+  public LabelType createLabel(ProjectConfig config, String label, LabelDefinitionInput input)
+      throws BadRequestException, ResourceConflictException {
+    if (config.getLabelSections().containsKey(label)) {
+      throw new ResourceConflictException(String.format("label %s already exists", label));
+    }
+
+    for (String labelName : config.getLabelSections().keySet()) {
+      if (labelName.equalsIgnoreCase(label)) {
+        throw new ResourceConflictException(
+            String.format("label %s conflicts with existing label %s", label, labelName));
+      }
+    }
+
+    if (input.values == null || input.values.isEmpty()) {
+      throw new BadRequestException("values are required");
+    }
+
+    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
+
+    LabelType labelType;
+    try {
+      labelType = new LabelType(label, values);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("invalid name: " + label, e);
+    }
+
+    if (input.function != null && !input.function.trim().isEmpty()) {
+      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+    } else {
+      labelType.setFunction(LabelFunction.MAX_WITH_BLOCK);
+    }
+
+    if (input.defaultValue != null) {
+      labelType.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+    }
+
+    if (input.branches != null) {
+      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+    }
+
+    if (input.canOverride != null) {
+      labelType.setCanOverride(input.canOverride);
+    }
+
+    if (input.copyAnyScore != null) {
+      labelType.setCopyAnyScore(input.copyAnyScore);
+    }
+
+    if (input.copyMinScore != null) {
+      labelType.setCopyMinScore(input.copyMinScore);
+    }
+
+    if (input.copyMaxScore != null) {
+      labelType.setCopyMaxScore(input.copyMaxScore);
+    }
+
+    if (input.copyAllScoresIfNoChange != null) {
+      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+    }
+
+    if (input.copyAllScoresIfNoCodeChange != null) {
+      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+    }
+
+    if (input.copyAllScoresOnTrivialRebase != null) {
+      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+    }
+
+    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+          input.copyAllScoresOnMergeFirstParentUpdate);
+    }
+
+    if (input.allowPostSubmit != null) {
+      labelType.setAllowPostSubmit(input.allowPostSubmit);
+    }
+
+    if (input.ignoreSelfApproval != null) {
+      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+    }
+
+    config.getLabelSections().put(labelType.getName(), labelType);
+
+    return labelType;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 5464abf..531640c 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -77,12 +77,10 @@
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
 
-      if (!config.getLabelSections().containsKey(rsrc.getLabelType().getName())) {
+      if (!deleteLabel(config, rsrc.getLabelType().getName())) {
         throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
       }
 
-      config.getLabelSections().remove(rsrc.getLabelType().getName());
-
       if (input.commitMessage != null) {
         md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
       } else {
@@ -96,4 +94,20 @@
 
     return Response.none();
   }
+
+  /**
+   * Delete the given label from the given project config.
+   *
+   * @param config the project config from which the label should be deleted
+   * @param labelName the name of the label that should be deleted
+   * @return {@code true} if the label was deleted, {@code false} if the label was not found
+   */
+  public boolean deleteLabel(ProjectConfig config, String labelName) {
+    if (!config.getLabelSections().containsKey(labelName)) {
+      return false;
+    }
+
+    config.getLabelSections().remove(labelName);
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 2c76cbd..5b3ea30 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -72,6 +72,7 @@
     get(LABEL_KIND).to(GetLabel.class);
     put(LABEL_KIND).to(SetLabel.class);
     delete(LABEL_KIND).to(DeleteLabel.class);
+    postOnCollection(LABEL_KIND).to(PostLabels.class);
 
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
new file mode 100644
index 0000000..8835359
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2019 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.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** REST endpoint that allows to add, update and delete label definitions in a batch. */
+@Singleton
+public class PostLabels
+    implements RestCollectionModifyView<ProjectResource, LabelResource, BatchLabelInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final DeleteLabel deleteLabel;
+  private final CreateLabel createLabel;
+  private final SetLabel setLabel;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public PostLabels(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      DeleteLabel deleteLabel,
+      CreateLabel createLabel,
+      SetLabel setLabel,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.deleteLabel = deleteLabel;
+    this.createLabel = createLabel;
+    this.setLabel = setLabel;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource rsrc, BatchLabelInput input)
+      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+          ConfigInvalidException, BadRequestException, ResourceConflictException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new BatchLabelInput();
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      boolean dirty = false;
+
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (input.delete != null && !input.delete.isEmpty()) {
+        for (String labelName : input.delete) {
+          if (!deleteLabel.deleteLabel(config, labelName.trim())) {
+            throw new UnprocessableEntityException(String.format("label %s not found", labelName));
+          }
+        }
+        dirty = true;
+      }
+
+      if (input.create != null && !input.create.isEmpty()) {
+        for (LabelDefinitionInput labelInput : input.create) {
+          if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
+            throw new BadRequestException("label name is required for new label");
+          }
+          if (labelInput.commitMessage != null) {
+            throw new BadRequestException("commit message on label definition input not supported");
+          }
+          createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+        }
+        dirty = true;
+      }
+
+      if (input.update != null && !input.update.isEmpty()) {
+        for (Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
+          LabelType labelType = config.getLabelSections().get(e.getKey().trim());
+          if (labelType == null) {
+            throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
+          }
+          if (e.getValue().commitMessage != null) {
+            throw new BadRequestException("commit message on label definition input not supported");
+          }
+          setLabel.updateLabel(config, labelType, e.getValue());
+        }
+        dirty = true;
+      }
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Update labels");
+      }
+
+      if (dirty) {
+        config.commit(md);
+        projectCache.evict(rsrc.getProjectState().getProject());
+      }
+    }
+
+    return Response.ok("");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index b7cffce..824b4ed 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -80,117 +80,9 @@
     LabelType labelType = rsrc.getLabelType();
 
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      boolean dirty = false;
-
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getLabelSections().remove(labelType.getName());
 
-      if (input.name != null) {
-        String newName = input.name.trim();
-        if (newName.isEmpty()) {
-          throw new BadRequestException("name cannot be empty");
-        }
-        if (!newName.equals(labelType.getName())) {
-          if (config.getLabelSections().containsKey(newName)) {
-            throw new ResourceConflictException(String.format("name %s already in use", newName));
-          }
-
-          for (String labelName : config.getLabelSections().keySet()) {
-            if (labelName.equalsIgnoreCase(newName)) {
-              throw new ResourceConflictException(
-                  String.format("name %s conflicts with existing label %s", newName, labelName));
-            }
-          }
-
-          try {
-            labelType.setName(newName);
-          } catch (IllegalArgumentException e) {
-            throw new BadRequestException("invalid name: " + input.name, e);
-          }
-          dirty = true;
-        }
-      }
-
-      if (input.function != null) {
-        if (input.function.trim().isEmpty()) {
-          throw new BadRequestException("function cannot be empty");
-        }
-        labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
-        dirty = true;
-      }
-
-      if (input.values != null) {
-        if (input.values.isEmpty()) {
-          throw new BadRequestException("values cannot be empty");
-        }
-        labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
-        dirty = true;
-      }
-
-      if (input.defaultValue != null) {
-        labelType.setDefaultValue(
-            LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
-        dirty = true;
-      }
-
-      if (input.branches != null) {
-        labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
-        dirty = true;
-      }
-
-      if (input.canOverride != null) {
-        labelType.setCanOverride(input.canOverride);
-        dirty = true;
-      }
-
-      if (input.copyAnyScore != null) {
-        labelType.setCopyAnyScore(input.copyAnyScore);
-        dirty = true;
-      }
-
-      if (input.copyMinScore != null) {
-        labelType.setCopyMinScore(input.copyMinScore);
-        dirty = true;
-      }
-
-      if (input.copyMaxScore != null) {
-        labelType.setCopyMaxScore(input.copyMaxScore);
-        dirty = true;
-      }
-
-      if (input.copyAllScoresIfNoChange != null) {
-        labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      }
-
-      if (input.copyAllScoresIfNoCodeChange != null) {
-        labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-        dirty = true;
-      }
-
-      if (input.copyAllScoresOnTrivialRebase != null) {
-        labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-        dirty = true;
-      }
-
-      if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-        labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-            input.copyAllScoresOnMergeFirstParentUpdate);
-        dirty = true;
-      }
-
-      if (input.allowPostSubmit != null) {
-        labelType.setAllowPostSubmit(input.allowPostSubmit);
-        dirty = true;
-      }
-
-      if (input.ignoreSelfApproval != null) {
-        labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
-        dirty = true;
-      }
-
-      if (dirty) {
-        config.getLabelSections().put(labelType.getName(), labelType);
-
+      if (updateLabel(config, labelType, input)) {
         if (input.commitMessage != null) {
           md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
         } else {
@@ -203,4 +95,128 @@
     }
     return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
   }
+
+  /**
+   * Updates the given label.
+   *
+   * @param config the project config
+   * @param labelType the label type that should be updated
+   * @param input the input that describes the label update
+   * @return whether the label type was modified
+   * @throws BadRequestException if there was invalid data in the input
+   * @throws ResourceConflictException if the update cannot be applied due to a conflict
+   */
+  public boolean updateLabel(ProjectConfig config, LabelType labelType, LabelDefinitionInput input)
+      throws BadRequestException, ResourceConflictException {
+    boolean dirty = false;
+
+    config.getLabelSections().remove(labelType.getName());
+
+    if (input.name != null) {
+      String newName = input.name.trim();
+      if (newName.isEmpty()) {
+        throw new BadRequestException("name cannot be empty");
+      }
+      if (!newName.equals(labelType.getName())) {
+        if (config.getLabelSections().containsKey(newName)) {
+          throw new ResourceConflictException(String.format("name %s already in use", newName));
+        }
+
+        for (String labelName : config.getLabelSections().keySet()) {
+          if (labelName.equalsIgnoreCase(newName)) {
+            throw new ResourceConflictException(
+                String.format("name %s conflicts with existing label %s", newName, labelName));
+          }
+        }
+
+        try {
+          labelType.setName(newName);
+        } catch (IllegalArgumentException e) {
+          throw new BadRequestException("invalid name: " + input.name, e);
+        }
+        dirty = true;
+      }
+    }
+
+    if (input.function != null) {
+      if (input.function.trim().isEmpty()) {
+        throw new BadRequestException("function cannot be empty");
+      }
+      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+      dirty = true;
+    }
+
+    if (input.values != null) {
+      if (input.values.isEmpty()) {
+        throw new BadRequestException("values cannot be empty");
+      }
+      labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
+      dirty = true;
+    }
+
+    if (input.defaultValue != null) {
+      labelType.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+      dirty = true;
+    }
+
+    if (input.branches != null) {
+      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+      dirty = true;
+    }
+
+    if (input.canOverride != null) {
+      labelType.setCanOverride(input.canOverride);
+      dirty = true;
+    }
+
+    if (input.copyAnyScore != null) {
+      labelType.setCopyAnyScore(input.copyAnyScore);
+      dirty = true;
+    }
+
+    if (input.copyMinScore != null) {
+      labelType.setCopyMinScore(input.copyMinScore);
+      dirty = true;
+    }
+
+    if (input.copyMaxScore != null) {
+      labelType.setCopyMaxScore(input.copyMaxScore);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresIfNoChange != null) {
+      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+    }
+
+    if (input.copyAllScoresIfNoCodeChange != null) {
+      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresOnTrivialRebase != null) {
+      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+          input.copyAllScoresOnMergeFirstParentUpdate);
+      dirty = true;
+    }
+
+    if (input.allowPostSubmit != null) {
+      labelType.setAllowPostSubmit(input.allowPostSubmit);
+      dirty = true;
+    }
+
+    if (input.ignoreSelfApproval != null) {
+      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+      dirty = true;
+    }
+
+    config.getLabelSections().put(labelType.getName(), labelType);
+
+    return dirty;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index b8ab752..55eeaf4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -83,7 +83,8 @@
               .expectedResponseCode(SC_NOT_FOUND)
               .build(),
           RestCall.get("/projects/%s/dashboards"),
-          RestCall.put("/projects/%s/labels/new-label"));
+          RestCall.put("/projects/%s/labels/new-label"),
+          RestCall.post("/projects/%s/labels/"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
new file mode 100644
index 0000000..9e6b051
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -0,0 +1,456 @@
+// Copyright (C) 2019 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostLabelsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(new BatchLabelInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(new BatchLabelInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void deleteNonExistingLabel() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void deleteLabels() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isNotEmpty();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo", "Bar");
+    gApi.projects().name(project.get()).labels(input);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void deleteLabels_labelNamesAreTrimmed() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isNotEmpty();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of(" Foo ", " Bar ");
+    gApi.projects().name(project.get()).labels(input);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteTheSameLabelTwice() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo", "Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.name = "Code-Review";
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(labelInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithTheSameName() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, fooInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithNamesThatAreTheSameAfterTrim() throws Exception {
+    LabelDefinitionInput foo1Input = new LabelDefinitionInput();
+    foo1Input.name = "Foo";
+    foo1Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput foo2Input = new LabelDefinitionInput();
+    foo2Input.name = " Foo ";
+    foo2Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(foo1Input, foo2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithConflictingNames() throws Exception {
+    LabelDefinitionInput foo1Input = new LabelDefinitionInput();
+    foo1Input.name = "Foo";
+    foo1Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput foo2Input = new LabelDefinitionInput();
+    foo2Input.name = "foo";
+    foo2Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(foo1Input, foo2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label foo conflicts with existing label Foo");
+  }
+
+  @Test
+  public void createLabels() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput barInput = new LabelDefinitionInput();
+    barInput.name = "Bar";
+    barInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void createLabels_labelNamesAreTrimmed() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = " Foo ";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput barInput = new LabelDefinitionInput();
+    barInput.name = " Bar ";
+    barInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void cannotCreateLabelWithoutName() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(new LabelDefinitionInput());
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label name is required for new label");
+  }
+
+  @Test
+  public void cannotSetCommitMessageOnLabelDefinitionInputForCreate() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.name = "Foo";
+    labelInput.commitMessage = "Create Label Foo";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(labelInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message on label definition input not supported");
+  }
+
+  @Test
+  public void updateNonExistingLabel() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Foo", new LabelDefinitionInput());
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void updateLabels() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooUpdate = new LabelDefinitionInput();
+    fooUpdate.function = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    LabelDefinitionInput barUpdate = new LabelDefinitionInput();
+    barUpdate.name = "Baz";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    assertThat(gApi.projects().name(project.get()).label("Foo").get().function)
+        .isEqualTo(fooUpdate.function);
+    assertThat(gApi.projects().name(project.get()).label("Baz").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).label("Bar").get());
+  }
+
+  @Test
+  public void updateLabels_labelNamesAreTrimmed() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooUpdate = new LabelDefinitionInput();
+    fooUpdate.function = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    LabelDefinitionInput barUpdate = new LabelDefinitionInput();
+    barUpdate.name = "Baz";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of(" Foo ", fooUpdate, " Bar ", barUpdate);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    assertThat(gApi.projects().name(project.get()).label("Foo").get().function)
+        .isEqualTo(fooUpdate.function);
+    assertThat(gApi.projects().name(project.get()).label("Baz").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).label("Bar").get());
+  }
+
+  @Test
+  public void cannotSetCommitMessageOnLabelDefinitionInputForUpdate() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.commitMessage = "Update label";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Code-Review", labelInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message on label definition input not supported");
+  }
+
+  @Test
+  public void deleteAndRecreateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooInput.function);
+  }
+
+  @Test
+  public void deleteRecreateAndUpdateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooCreateInput = new LabelDefinitionInput();
+    fooCreateInput.name = "Foo";
+    fooCreateInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooCreateInput.values =
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput fooUpdateInput = new LabelDefinitionInput();
+    fooUpdateInput.function = LabelFunction.ANY_WITH_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooCreateInput);
+    input.update = ImmutableMap.of("Foo", fooUpdateInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooUpdateInput.function);
+  }
+
+  @Test
+  public void cannotDeleteAndUpdateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.update = ImmutableMap.of("Foo", fooInput);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void createAndUpdateLabel() throws Exception {
+    LabelDefinitionInput fooCreateInput = new LabelDefinitionInput();
+    fooCreateInput.name = "Foo";
+    fooCreateInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooCreateInput.values =
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput fooUpdateInput = new LabelDefinitionInput();
+    fooUpdateInput.function = LabelFunction.ANY_WITH_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooCreateInput);
+    input.update = ImmutableMap.of("Foo", fooUpdateInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooUpdateInput.function);
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    gApi.projects().name(allProjects.get()).labels(new BatchLabelInput());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update labels");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.commitMessage = "Batch Update Labels";
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.commitMessage = " Batch Update Labels ";
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Batch Update Labels");
+  }
+}