From 467aecb7d0fad45eb1cab274160ac0078588077a Mon Sep 17 00:00:00 2001 From: David Pursehouse Date: Fri, 2 Dec 2016 14:18:42 +0900 Subject: [PATCH] Add REST API endpoint to delete multiple tags Change-Id: I1a1146aaf1b72ad369390cd916fbe62cf4683467 --- Documentation/rest-api-projects.txt | 44 +++++ .../acceptance/rest/project/DeleteTagsIT.java | 153 ++++++++++++++++++ .../api/projects/DeleteTagsInput.java | 21 +++ .../extensions/api/projects/ProjectApi.java | 6 + .../server/api/projects/ProjectApiImpl.java | 20 ++- .../gerrit/server/project/DeleteRef.java | 18 ++- .../gerrit/server/project/DeleteTags.java | 51 ++++++ .../google/gerrit/server/project/Module.java | 1 + 8 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java create mode 100644 gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java create mode 100644 gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt index 3708efe9e4..d6338f72fa 100644 --- a/Documentation/rest-api-projects.txt +++ b/Documentation/rest-api-projects.txt @@ -1904,6 +1904,38 @@ Deletes a tag. HTTP/1.1 204 No Content ---- +[[delete-tags]] +=== Delete Tags +-- +'POST /projects/link:#project-name[\{project-name\}]/tags:delete' +-- + +Delete one or more tags. + +The tags to be deleted must be provided in the request body as a +link:#delete-tags-input[DeleteTagsInput] entity. + +.Request +---- + POST /projects/MyProject/tags:delete HTTP/1.0 + Content-Type: application/json;charset=UTF-8 + + { + "tags": [ + "v1.0", + "v2.0" + ] + } +---- + +.Response +---- + HTTP/1.1 204 No Content +---- + +If some tags could not be deleted, the response is "`409 Conflict`" and the +error message is contained in the response body. + [[commit-endpoints]] == Commit Endpoints @@ -2539,6 +2571,18 @@ be deleted. deleted. |========================== +[[delete-tags-input]] +=== DeleteTagsInput +The `DeleteTagsInput` entity contains information about tags that should +be deleted. + +[options="header",width="50%",cols="1,6"] +|========================== +|Field Name |Description +|`tags` |A list of tag names that identify the tags that should be +deleted. +|========================== + [[gc-input]] === GCInput The `GCInput` entity contains information to run the Git garbage diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java new file mode 100644 index 0000000000..fb39221c0e --- /dev/null +++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java @@ -0,0 +1,153 @@ +// Copyright (C) 2016 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 org.junit.Assert.fail; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.NoHttpd; +import com.google.gerrit.extensions.api.projects.TagInput; +import com.google.gerrit.extensions.api.projects.DeleteTagsInput; +import com.google.gerrit.extensions.api.projects.ProjectApi; +import com.google.gerrit.extensions.api.projects.TagInfo; +import com.google.gerrit.extensions.restapi.ResourceConflictException; + +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; + +@NoHttpd +public class DeleteTagsIT extends AbstractDaemonTest { + private static final List TAGS = ImmutableList.of( + "refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3"); + + @Before + public void setUp() throws Exception { + for (String name : TAGS) { + project().tag(name).create(new TagInput()); + } + assertTags(TAGS); + } + + @Test + public void deleteTags() throws Exception { + HashMap initialRevisions = initialRevisions(TAGS); + DeleteTagsInput input = new DeleteTagsInput(); + input.tags = TAGS; + project().deleteTags(input); + assertTagsDeleted(); + assertRefUpdatedEvents(initialRevisions); + } + + @Test + public void deleteTagsForbidden() throws Exception { + DeleteTagsInput input = new DeleteTagsInput(); + input.tags = TAGS; + setApiUser(user); + try { + project().deleteTags(input); + fail("Expected ResourceConflictException"); + } catch (ResourceConflictException e) { + assertThat(e).hasMessage(errorMessageForTags(TAGS)); + } + setApiUser(admin); + assertTags(TAGS); + } + + @Test + public void deleteTagsNotFound() throws Exception { + DeleteTagsInput input = new DeleteTagsInput(); + List tags = Lists.newArrayList(TAGS); + tags.add("refs/tags/does-not-exist"); + input.tags = tags; + try { + project().deleteTags(input); + fail("Expected ResourceConflictException"); + } catch (ResourceConflictException e) { + assertThat(e).hasMessage(errorMessageForTags( + ImmutableList.of("refs/tags/does-not-exist"))); + } + assertTagsDeleted(); + } + + @Test + public void deleteTagsNotFoundContinue() throws Exception { + // If it fails on the first tag in the input, it should still + // continue to process the remaining tags. + DeleteTagsInput input = new DeleteTagsInput(); + List tags = Lists.newArrayList("refs/tags/does-not-exist"); + tags.addAll(TAGS); + input.tags = tags; + try { + project().deleteTags(input); + fail("Expected ResourceConflictException"); + } catch (ResourceConflictException e) { + assertThat(e).hasMessage(errorMessageForTags( + ImmutableList.of("refs/tags/does-not-exist"))); + } + assertTagsDeleted(); + } + + private String errorMessageForTags(List tags) { + StringBuilder message = new StringBuilder(); + for (String tag : tags) { + message.append("Cannot delete ") + .append(tag) + .append(": it doesn't exist or you do not have permission ") + .append("to delete it\n"); + } + return message.toString(); + } + + private HashMap initialRevisions(List tags) + throws Exception { + HashMap result = new HashMap<>(); + for (String tag : tags) { + result.put(tag, getRemoteHead(project, tag)); + } + return result; + } + + private void assertRefUpdatedEvents(HashMap revisions) + throws Exception { + for (String tag : revisions.keySet()) { + RevCommit revision = revisions.get(tag); + eventRecorder.assertRefUpdatedEvents(project.get(), tag, + null, revision, + revision, null); + } + } + + private ProjectApi project() throws Exception { + return gApi.projects().name(project.get()); + } + + private void assertTags(List expected) throws Exception { + List actualTags = project().tags().get(); + Iterable actualNames = Iterables.transform(actualTags, b -> b.ref); + assertThat(actualNames).containsExactlyElementsIn(expected).inOrder(); + } + + private void assertTagsDeleted() throws Exception { + assertTags(ImmutableList.of()); + } +} diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java new file mode 100644 index 0000000000..b93362493c --- /dev/null +++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java @@ -0,0 +1,21 @@ +// Copyright (C) 2016 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.projects; + +import java.util.List; + +public class DeleteTagsInput { + public List tags; +} diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java index e111291c78..99450ded44 100644 --- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java +++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java @@ -40,6 +40,7 @@ public interface ProjectApi { ListRefsRequest tags(); void deleteBranches(DeleteBranchesInput in) throws RestApiException; + void deleteTags(DeleteTagsInput in) throws RestApiException; abstract class ListRefsRequest { protected int limit; @@ -205,5 +206,10 @@ public interface ProjectApi { public void deleteBranches(DeleteBranchesInput in) throws RestApiException { throw new NotImplementedException(); } + + @Override + public void deleteTags(DeleteTagsInput in) throws RestApiException { + throw new NotImplementedException(); + } } } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java index b28258c136..8f7f1b21f6 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java @@ -24,6 +24,7 @@ import com.google.gerrit.extensions.api.projects.ChildProjectApi; import com.google.gerrit.extensions.api.projects.ConfigInfo; import com.google.gerrit.extensions.api.projects.ConfigInput; import com.google.gerrit.extensions.api.projects.DeleteBranchesInput; +import com.google.gerrit.extensions.api.projects.DeleteTagsInput; import com.google.gerrit.extensions.api.projects.DescriptionInput; import com.google.gerrit.extensions.api.projects.ProjectApi; import com.google.gerrit.extensions.api.projects.ProjectInput; @@ -40,6 +41,7 @@ import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.project.ChildProjectsCollection; import com.google.gerrit.server.project.CreateProject; import com.google.gerrit.server.project.DeleteBranches; +import com.google.gerrit.server.project.DeleteTags; import com.google.gerrit.server.project.GetAccess; import com.google.gerrit.server.project.GetConfig; import com.google.gerrit.server.project.GetDescription; @@ -87,6 +89,7 @@ public class ProjectApiImpl implements ProjectApi { private final ListBranches listBranches; private final ListTags listTags; private final DeleteBranches deleteBranches; + private final DeleteTags deleteTags; @AssistedInject ProjectApiImpl(CurrentUser user, @@ -107,11 +110,12 @@ public class ProjectApiImpl implements ProjectApi { ListBranches listBranches, ListTags listTags, DeleteBranches deleteBranches, + DeleteTags deleteTags, @Assisted ProjectResource project) { this(user, createProjectFactory, projectApi, projects, getDescription, putDescription, childApi, children, projectJson, branchApiFactory, tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches, - listTags, deleteBranches, project, null); + listTags, deleteBranches, deleteTags, project, null); } @AssistedInject @@ -133,11 +137,12 @@ public class ProjectApiImpl implements ProjectApi { ListBranches listBranches, ListTags listTags, DeleteBranches deleteBranches, + DeleteTags deleteTags, @Assisted String name) { this(user, createProjectFactory, projectApi, projects, getDescription, putDescription, childApi, children, projectJson, branchApiFactory, tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches, - listTags, deleteBranches, null, name); + listTags, deleteBranches, deleteTags, null, name); } private ProjectApiImpl(CurrentUser user, @@ -158,6 +163,7 @@ public class ProjectApiImpl implements ProjectApi { ListBranches listBranches, ListTags listTags, DeleteBranches deleteBranches, + DeleteTags deleteTags, ProjectResource project, String name) { this.user = user; @@ -180,6 +186,7 @@ public class ProjectApiImpl implements ProjectApi { this.listBranches = listBranches; this.listTags = listTags; this.deleteBranches = deleteBranches; + this.deleteTags = deleteTags; } @Override @@ -345,6 +352,15 @@ public class ProjectApiImpl implements ProjectApi { } } + @Override + public void deleteTags(DeleteTagsInput in) throws RestApiException { + try { + deleteTags.apply(checkExists(), in); + } catch (OrmException | IOException e) { + throw new RestApiException("Cannot delete tags", e); + } + } + private ProjectResource checkExists() throws ResourceNotFoundException { if (project == null) { throw new ResourceNotFoundException(name); diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java index 2c54603c05..5623efc346 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java @@ -45,6 +45,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; public class DeleteRef { private static final Logger log = LoggerFactory.getLogger(DeleteRef.class); @@ -59,6 +60,7 @@ public class DeleteRef { private final Provider queryProvider; private final ProjectResource resource; private final List refsToDelete; + private String prefix; public interface Factory { public DeleteRef create(ProjectResource r); @@ -90,6 +92,11 @@ public class DeleteRef { return this; } + public DeleteRef prefix(String prefix) { + this.prefix = prefix; + return this; + } + public void delete() throws OrmException, IOException, ResourceConflictException { if (!refsToDelete.isEmpty()) { @@ -106,6 +113,9 @@ public class DeleteRef { private void deleteSingleRef(Repository r) throws IOException, ResourceConflictException { String ref = refsToDelete.get(0); + if (prefix != null && !ref.startsWith(prefix)) { + ref = prefix + ref; + } RefUpdate.Result result; RefUpdate u = r.updateRef(ref); u.setForceUpdate(true); @@ -160,7 +170,13 @@ public class DeleteRef { private void deleteMultipleRefs(Repository r) throws OrmException, IOException, ResourceConflictException { BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate(); - for (String ref : refsToDelete) { + List refs = prefix == null + ? refsToDelete + : refsToDelete.stream().map( + ref -> ref.startsWith(prefix) + ? ref + : prefix + ref).collect(Collectors.toList()); + for (String ref : refs) { batchUpdate.addCommand(createDeleteCommand(resource, r, ref)); } try (RevWalk rw = new RevWalk(r)) { diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java new file mode 100644 index 0000000000..813012b5c9 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java @@ -0,0 +1,51 @@ +// Copyright (C) 2016 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.project; + +import static org.eclipse.jgit.lib.Constants.R_TAGS; + +import com.google.gerrit.extensions.api.projects.DeleteTagsInput; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import java.io.IOException; + +@Singleton +public class DeleteTags + implements RestModifyView { + private final DeleteRef.Factory deleteRefFactory; + + @Inject + DeleteTags(DeleteRef.Factory deleteRefFactory) { + this.deleteRefFactory = deleteRefFactory; + } + + @Override + public Response apply(ProjectResource project, DeleteTagsInput input) + throws OrmException, RestApiException, IOException { + + if (input == null || input.tags == null || input.tags.isEmpty()) { + throw new BadRequestException("tags must be specified"); + } + + deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete(); + return Response.none(); + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java index de789f77b3..7579398291 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java @@ -82,6 +82,7 @@ public class Module extends RestApiModule { get(TAG_KIND).to(GetTag.class); put(TAG_KIND).to(PutTag.class); delete(TAG_KIND).to(DeleteTag.class); + post(PROJECT_KIND, "tags:delete").to(DeleteTags.class); factory(CreateTag.Factory.class); child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);