diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index 4a2d3e77ae..e96233a5f3 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -62,5 +62,10 @@ public enum Command /** * @since 2.10.0 */ - LOOKUP; + LOOKUP, + + /** + * @since 2.11.0 + */ + TAG; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 15c2f3f523..c1b1321419 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -377,6 +377,18 @@ public final class RepositoryService implements Closeable { repository); } + /** + * The tag command allows the management of repository tags. + * + * @return instance of {@link TagCommandBuilder} + * + * @throws CommandNotSupportedException if the command is not supported + * by the implementation of the repository service provider. + */ + public TagCommandBuilder getTagCommand() { + return new TagCommandBuilder(repository, provider.getTagCommand()); + } + /** * The unbundle command restores a repository from the given bundle. * diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java index ceb7f5ab79..ca2a0f176f 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java @@ -44,35 +44,43 @@ public class TagCommandBuilder { this.eventBus = ScmEventBus.getInstance(); } - TagCreateCommandBuilder create() { + public TagCreateCommandBuilder create() { return new TagCreateCommandBuilder(); } - TagDeleteCommandBuilder delete() { + public TagDeleteCommandBuilder delete() { return new TagDeleteCommandBuilder(); } - private class TagCreateCommandBuilder { + public class TagCreateCommandBuilder { private TagCreateRequest request = new TagCreateRequest(); - void setRevision(String revision) { + public TagCreateCommandBuilder setRevision(String revision) { request.setRevision(revision); + return this; } - void setName(String name) { + public TagCreateCommandBuilder setName(String name) { request.setName(name); + return this; } - Tag execute() throws IOException { + public Tag execute() throws IOException { Tag tag = command.create(request); eventBus.post(new TagCreatedEvent(repository, request.getRevision(), request.getName())); return tag; } } - private class TagDeleteCommandBuilder { + public class TagDeleteCommandBuilder { private TagDeleteRequest request = new TagDeleteRequest(); - void execute() throws IOException { + + public TagDeleteCommandBuilder setName(String name) { + request.setName(name); + return this; + } + + public void execute() throws IOException { command.delete(request); eventBus.post(new TagDeletedEvent(repository, request.getName())); } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index 091c9b46b3..62548280e7 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -246,6 +246,17 @@ public abstract class RepositoryServiceProvider implements Closeable throw new CommandNotSupportedException(Command.TAGS); } + /** + * Method description + * + * + * @return + */ + public TagCommand getTagCommand() + { + throw new CommandNotSupportedException(Command.TAGS); + } + /** * Method description * diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index b3dbcb5b6e..dc5dd4702b 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -51,6 +51,7 @@ public class VndMediaType { public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX; public static final String TAG = PREFIX + "tag" + SUFFIX; public static final String TAG_COLLECTION = PREFIX + "tagCollection" + SUFFIX; + public static final String TAG_REQUEST = PREFIX + "tagRequest" + SUFFIX; public static final String BRANCH = PREFIX + "branch" + SUFFIX; public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX; public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index d54320575d..a6f2145611 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -148,6 +148,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider return new GitTagsCommand(context, gpg); } + @Override + public TagCommand getTagCommand() { + return new GitTagCommand(context, gpg); + } + @Override public MergeCommand getMergeCommand() { return commandInjector.getInstance(GitMergeCommand.class); diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java index ce64b06982..b37918d4c1 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java @@ -271,6 +271,11 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider return new HgTagsCommand(context); } + @Override + public TagCommand getTagCommand() { + return new HgTagCommand(context); + } + //~--- fields --------------------------------------------------------------- /** Field description */ diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagCommand.java new file mode 100644 index 0000000000..6d738de6b8 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagCommand.java @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.aragost.javahg.Repository; +import com.google.common.base.Strings; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.repository.api.TagDeleteRequest; + +public class HgTagCommand extends AbstractCommand implements TagCommand { + + public HgTagCommand(HgCommandContext context) { + super(context); + } + + @Override + public Tag create(TagCreateRequest request) { + Repository repository = getContext().open(); + String rev = request.getRevision(); + if (Strings.isNullOrEmpty(rev)) { + rev = repository.tip().getNode(); + } + com.aragost.javahg.commands.TagCommand.on(repository) + .rev(rev) + .user("SCM-Manager") + .execute(request.getName()); + return new Tag(request.getName(), rev); + } + + @Override + public void delete(TagDeleteRequest request) { + Repository repository = getContext().open(); + com.aragost.javahg.commands.TagCommand.on(repository) + .user("SCM-Manager") + .remove() + .execute(request.getName()); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgTagCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgTagCommandTest.java new file mode 100644 index 0000000000..e1b7eff2f2 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgTagCommandTest.java @@ -0,0 +1,53 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.junit.Test; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.repository.api.TagDeleteRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HgTagCommandTest extends AbstractHgCommandTestBase { + + @Test + public void shouldCreateAndDeleteTagCorrectly() { + // Create + new HgTagCommand(cmdContext).create(new TagCreateRequest("79b6baf49711", "newtag")); + List tags = new HgTagsCommand(cmdContext).getTags(); + assertThat(tags).hasSize(2); + final Tag newTag = tags.get(1); + assertThat(newTag.getName()).isEqualTo("newtag"); + + // Delete + new HgTagCommand(cmdContext).delete(new TagDeleteRequest("newtag")); + tags = new HgTagsCommand(cmdContext).getTags(); + assertThat(tags).hasSize(1); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java index 7d4fd6dada..9100090b36 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java @@ -33,6 +33,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import java.time.Instant; +import java.util.List; @Getter @Setter @@ -46,6 +47,8 @@ public class TagDto extends HalRepresentation { @JsonInclude(JsonInclude.Include.NON_NULL) private Instant date; + private List signatures; + TagDto(Links links, Embedded embedded) { super(links, embedded); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRequestDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRequestDto.java new file mode 100644 index 0000000000..5d43914552 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRequestDto.java @@ -0,0 +1,45 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.NotEmpty; + +@Getter +@Setter +public class TagRequestDto { + // TODO: Validate revision via regex + @NotEmpty + @Length(min = 1, max = 100) + private String revision; + + // TODO: Validate name via regex + @NotEmpty + @Length(min = 1, max = 100) + private String name; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java index a0f9fe0e7c..b46656a02d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java @@ -21,14 +21,16 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import sonia.scm.NotFoundException; +import sonia.scm.repository.Branch; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -36,16 +38,22 @@ import sonia.scm.repository.Tag; import sonia.scm.repository.Tags; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.api.TagCommandBuilder; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; import java.io.IOException; +import java.net.URI; +import static sonia.scm.AlreadyExistsException.alreadyExists; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -54,14 +62,17 @@ public class TagRootResource { private final RepositoryServiceFactory serviceFactory; private final TagCollectionToDtoMapper tagCollectionToDtoMapper; private final TagToTagDtoMapper tagToTagDtoMapper; + private final ResourceLinks resourceLinks; @Inject public TagRootResource(RepositoryServiceFactory serviceFactory, TagCollectionToDtoMapper tagCollectionToDtoMapper, - TagToTagDtoMapper tagToTagDtoMapper) { + TagToTagDtoMapper tagToTagDtoMapper, + ResourceLinks resourceLinks) { this.serviceFactory = serviceFactory; this.tagCollectionToDtoMapper = tagCollectionToDtoMapper; this.tagToTagDtoMapper = tagToTagDtoMapper; + this.resourceLinks = resourceLinks; } @GET @@ -98,6 +109,53 @@ public class TagRootResource { } } + @POST + @Path("") + @Produces(VndMediaType.TAG_REQUEST) + @Operation(summary = "Create tag", description = "Creates a new tag and returns it", tags = "Repository") + @ApiResponse( + responseCode = "201", + description = "create success", + headers = @Header( + name = "Location", + description = "uri to the created tag", + schema = @Schema(type = "string") + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags") + @ApiResponse( + responseCode = "404", + description = "not found, no tag with the specified name available in the repository", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid TagRequestDto tagRequest) throws IOException { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + String revision = tagRequest.getRevision(); + String tagName = tagRequest.getName(); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + if (tagExists(tagName, repositoryService)) { + throw alreadyExists(entity(Tag.class, tagName).in(Repository.class, namespace + "/" + name)); + } + Repository repository = repositoryService.getRepository(); + RepositoryPermissions.push(repository).check(); + TagCommandBuilder tagCommandBuilder = repositoryService.getTagCommand(); + final Tag newTag = tagCommandBuilder.create() + .setRevision(revision) + .setName(tagName) + .execute(); + return Response.created(URI.create(resourceLinks.tag().self(namespace, name, newTag.getName()))).build(); + } + } @GET @Path("{tagName}") @@ -145,6 +203,45 @@ public class TagRootResource { } } + @DELETE + @Path("{tagName}") + @Produces(VndMediaType.TAG) + @Operation(summary = "Delete tag", description = "Deletes the tag provided in the path", tags = "Repository") + @ApiResponse( + responseCode = "200", + description = "success" + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags") + @ApiResponse( + responseCode = "404", + description = "not found, no tag with the specified name available in the repository", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public Response delete(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("tagName") String tagName) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + RepositoryPermissions.modify(repositoryService.getRepository()).check(); + + if (tagExists(tagName, repositoryService)) { + repositoryService.getTagCommand().delete() + .setName(tagName) + .execute(); + } + + return Response.noContent().build(); + } + } + private NotFoundException createNotFoundException(String namespace, String name, String tagName) { return notFound(entity("Tag", tagName).in("Repository", namespace + "/" + name)); } @@ -155,5 +252,9 @@ public class TagRootResource { return repositoryService.getTagsCommand().getTags(); } + private boolean tagExists(String tagName, RepositoryService repositoryService) throws IOException { + return getTags(repositoryService) + .getTagByName(tagName) != null; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java index 940b33dd05..78350ab114 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java @@ -52,6 +52,7 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper { @Mapping(target = "date", source = "date", qualifiedByName = "mapDate") @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + @Mapping(target = "signatures") public abstract TagDto map(Tag tag, @Context NamespaceAndName namespaceAndName); @ObjectFactory diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java index ebf71f6b85..efb5dcee4e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java @@ -29,10 +29,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Signature; +import sonia.scm.repository.SignatureStatus; import sonia.scm.repository.Tag; import java.net.URI; import java.time.Instant; +import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; @@ -66,4 +69,18 @@ class TagToTagDtoMapperTest { assertThat(dto.getDate()).isEqualTo(Instant.ofEpochMilli(now)); } + @Test + void shouldContainSignatureArray() { + TagDto dto = mapper.map(new Tag("1.0.0", "42"), new NamespaceAndName("hitchhiker", "hog")); + assertThat(dto.getSignatures()).isNotNull(); + } + + @Test + void shouldMapSignatures() { + final Tag tag = new Tag("1.0.0", "42"); + tag.addSignature(new Signature("29v391239v", "gpg", SignatureStatus.VERIFIED, "me", Collections.emptySet())); + TagDto dto = mapper.map(tag, new NamespaceAndName("hitchhiker", "hog")); + assertThat(dto.getSignatures()).isNotEmpty(); + } + }