diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aebcf622f..eb13f433f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show the date of the last commit for branches in the frontend ([#1439](https://github.com/scm-manager/scm-manager/pull/1439)) - Unify and add description to key view across user settings ([#1440](https://github.com/scm-manager/scm-manager/pull/1440)) - Healthcheck for docker image ([#1428](https://github.com/scm-manager/scm-manager/issues/1428) and [#1454](https://github.com/scm-manager/scm-manager/issues/1454)) +- Tags can now be added and deleted through the ui ([#1456](https://github.com/scm-manager/scm-manager/pull/1456)) +- The ui now displays tag signatures ([#1456](https://github.com/scm-manager/scm-manager/pull/1456)) ### Changed - Send mercurial hook callbacks over separate tcp socket instead of http ([#1416](https://github.com/scm-manager/scm-manager/pull/1416)) diff --git a/docs/de/user/repo/assets/repository-code-changeset-create-tag.png b/docs/de/user/repo/assets/repository-code-changeset-create-tag.png new file mode 100644 index 0000000000..7bc0b8bce7 Binary files /dev/null and b/docs/de/user/repo/assets/repository-code-changeset-create-tag.png differ diff --git a/docs/de/user/repo/assets/repository-code-changeset-with-tag.png b/docs/de/user/repo/assets/repository-code-changeset-with-tag.png new file mode 100644 index 0000000000..cd594e46b2 Binary files /dev/null and b/docs/de/user/repo/assets/repository-code-changeset-with-tag.png differ diff --git a/docs/de/user/repo/assets/repository-tag-detailView.png b/docs/de/user/repo/assets/repository-tag-detailView.png index 3bfd6bdcc9..ded2594a88 100644 Binary files a/docs/de/user/repo/assets/repository-tag-detailView.png and b/docs/de/user/repo/assets/repository-tag-detailView.png differ diff --git a/docs/de/user/repo/assets/repository-tag-signatures.png b/docs/de/user/repo/assets/repository-tag-signatures.png new file mode 100644 index 0000000000..09f6c4b9db Binary files /dev/null and b/docs/de/user/repo/assets/repository-tag-signatures.png differ diff --git a/docs/de/user/repo/code.md b/docs/de/user/repo/code.md index a779564ea0..09a8010465 100644 --- a/docs/de/user/repo/code.md +++ b/docs/de/user/repo/code.md @@ -34,6 +34,19 @@ Beispielsweise wird der Text hitchhiker/HeartOfGold@1a2b3c4 zu einem Link zu dem ![Repository-Code-Changesets](assets/repository-code-changesetDetails.png) +#### Tags + +Alle Tags eines Changesets werden in der oberen rechten Ecke der Detailseite angezeigt. + +![Repository-Code-Changesets](assets/repository-code-changeset-with-tag.png) + +#### Tags erstellen + +Neue Tags für ein Changeset können direkt in dessen Übersichtsseite erstellt werden. +Es muss lediglich ein gewünschter Name angegeben werden, welcher die gleichen Formatierungsbeschränkungen wie Branches erfüllt. + +![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png) + ### Datei Details Nach einem Klick auf eine Datei in den Sources landet man in der Detailansicht der Datei. Dabei sind je nach Dateiformat unterschiedliche Ansichten zu sehen: diff --git a/docs/de/user/repo/tags.md b/docs/de/user/repo/tags.md index 932a8a143b..377ee28b4d 100644 --- a/docs/de/user/repo/tags.md +++ b/docs/de/user/repo/tags.md @@ -11,3 +11,20 @@ Auf der Tags-Übersicht sind die existierenden Tags nach Erstelldatum absteigend Hier wird ein Befehl zum Arbeiten mit dem Tag auf einer Kommandozeile aufgeführt. ![Tag Detailseite](assets/repository-tag-detailView.png) + +#### Tag-Signaturen +Wenn mindestens eine Signatur für einen Tag existiert, wird der Verifizierungsstatus des Tags als Schlüsselsymbol hinter dessen Namen in der Detailansicht dargestellt. + +Ein Tag kann mehrere Signaturen haben. + +Abhängig vom Status der einzelnen Signaturen, wird das Symbol entsprechend eingefärbt: +- wenn mindestens eine Signatur ungültig ist, wird der Schlüssel `rot` dargestellt ANDERNFALLS +- wenn mindestens eine Signatur gültigt ist, wird der Schlüssel `grün` dargestellt ANDERNFALLS +- wird der Schlüssel `grau` dargestellt + +Wird der Mauszeiger über das Symbol bewegt, erscheint eine Liste aller Signaturen des Tags. + +![Tag Signatures](assets/repository-tag-signatures.png) + +### Tags löschen +Tags können direkt von der Übersicht aus oder auf der Detailseite gelöscht werden. diff --git a/docs/en/user/repo/assets/repository-code-changeset-create-tag.png b/docs/en/user/repo/assets/repository-code-changeset-create-tag.png new file mode 100644 index 0000000000..559dbf9241 Binary files /dev/null and b/docs/en/user/repo/assets/repository-code-changeset-create-tag.png differ diff --git a/docs/en/user/repo/assets/repository-code-changeset-with-tag.png b/docs/en/user/repo/assets/repository-code-changeset-with-tag.png new file mode 100644 index 0000000000..083472b8ac Binary files /dev/null and b/docs/en/user/repo/assets/repository-code-changeset-with-tag.png differ diff --git a/docs/en/user/repo/assets/repository-tag-detailView.png b/docs/en/user/repo/assets/repository-tag-detailView.png index a55d1ef587..1516ac6ff4 100644 Binary files a/docs/en/user/repo/assets/repository-tag-detailView.png and b/docs/en/user/repo/assets/repository-tag-detailView.png differ diff --git a/docs/en/user/repo/assets/repository-tag-signatures.png b/docs/en/user/repo/assets/repository-tag-signatures.png new file mode 100644 index 0000000000..38f9a45c93 Binary files /dev/null and b/docs/en/user/repo/assets/repository-tag-signatures.png differ diff --git a/docs/en/user/repo/assets/repository-tags-overview.png b/docs/en/user/repo/assets/repository-tags-overview.png index 32c9d9aaea..f4f5b6a470 100644 Binary files a/docs/en/user/repo/assets/repository-tags-overview.png and b/docs/en/user/repo/assets/repository-tags-overview.png differ diff --git a/docs/en/user/repo/code.md b/docs/en/user/repo/code.md index b0037d563b..c297f06f44 100644 --- a/docs/en/user/repo/code.md +++ b/docs/en/user/repo/code.md @@ -32,9 +32,20 @@ You can expand the diffs gradually or completely by clicking on the blue bars. If commit links formatted like "namespace/name@commitId" are used in the changeset description they will be rendered to internal links. For example the text hitchhiker/HeartOfGold@1a2b3c4 will be transformed to a link directing to the commit 1a2b3c4 of the repository hitchhiker/heartOfGold. - ![Repository-Code-Changesets](assets/repository-code-changesetDetails.png) +#### Tags + +All tags for a changeset are displayed in the top-right corner of the details page. + +![Repository-Code-Changesets](assets/repository-code-changeset-with-tag.png) + +#### Creating Tags +New tags for a changeset can be created directly on its details page. +Only a name has to be provided that meets the same formatting conditions as branches. + +![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png) + ### File Details After clicking on a file in the sources, the details of the file are shown. Depending on the format of the file, there are different views: diff --git a/docs/en/user/repo/tags.md b/docs/en/user/repo/tags.md index 1b5462ed3c..1d96b58b0a 100644 --- a/docs/en/user/repo/tags.md +++ b/docs/en/user/repo/tags.md @@ -11,3 +11,20 @@ The tag overview shows the tags that exist for this repository. By clicking on a This page shows a command to work with the tag on the command line. ![Tag Details Page](assets/repository-tag-detailView.png) + +#### Tag Signatures +If there is at least one signature on the tag, the verification status is displayed as a key icon after its name on its details page. + +A tag can have multiple signatures. + +Depending on the status of the individual signatures, the key will have a distinct color indicator: +- if at least one signature on the tag is invalid, the key will be `red` OTHERWISE +- if at least one signature is valid, the key will be `green` OTHERWISE +- the key will be `gray` + +If you hover the key icon, a list of all signatures on the tag will pop up. + +![Tag Signatures](assets/repository-tag-signatures.png) + +### Deleting Tags +Tags can be deleted directly on the tags overview page or on the details page of the tag. diff --git a/scm-core/src/main/java/sonia/scm/repository/Tag.java b/scm-core/src/main/java/sonia/scm/repository/Tag.java index cdfac1c0e6..933faa403d 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Tag.java +++ b/scm-core/src/main/java/sonia/scm/repository/Tag.java @@ -28,7 +28,10 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; +import java.util.regex.Pattern; /** * Represents a tag in a repository. @@ -41,9 +44,14 @@ import java.util.Optional; @Getter public final class Tag { + public static final String VALID_REV = "[0-9a-z]+"; + public static final Pattern VALID_REV_PATTERN = Pattern.compile(VALID_REV); + private final String name; private final String revision; private final Long date; + private final List signatures = new ArrayList<>(); + private final Boolean deletable; /** * Constructs a new tag. @@ -65,9 +73,24 @@ public final class Tag { * @since 2.5.0 */ public Tag(String name, String revision, Long date) { + this(name, revision, date, true); + } + + /** + * Constructs a new tag. + * + * @param name name of the tag + * @param revision tagged revision + * @param date the creation timestamp (milliseconds) of the tag + * @param deletable whether this tag can be deleted + * + * @since 2.11.0 + */ + public Tag(String name, String revision, Long date, Boolean deletable) { this.name = name; this.revision = revision; this.date = date; + this.deletable = deletable; } /** @@ -89,4 +112,8 @@ public final class Tag { public Optional getDate() { return Optional.ofNullable(date); } + + public void addSignature(Signature signature) { + this.signatures.add(signature); + } } 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..ef4c048803 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(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 new file mode 100644 index 0000000000..b64bc3ce97 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java @@ -0,0 +1,103 @@ +/* + * 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.api; + +import sonia.scm.repository.Tag; +import sonia.scm.repository.spi.TagCommand; + +import java.io.IOException; + +/** + * @since 2.11.0 + */ +public final class TagCommandBuilder { + private final TagCommand command; + + public TagCommandBuilder(TagCommand command) { + this.command = command; + } + + /** + * Initialize a command to tag a revision. + * + * Set parameters and call {@link TagCreateCommandBuilder#execute()}. + * + * @since 2.11.0 + */ + public TagCreateCommandBuilder create() { + return new TagCreateCommandBuilder(); + } + + /** + * Initialize a command to delete a tag. + * + * Set parameters and call {@link TagDeleteCommandBuilder#execute()}. + * + * @since 2.11.0 + */ + public TagDeleteCommandBuilder delete() { + return new TagDeleteCommandBuilder(); + } + + public final class TagCreateCommandBuilder { + private final TagCreateRequest request = new TagCreateRequest(); + + /** + * @param revision The revision identifier for which to create the tag + */ + public TagCreateCommandBuilder setRevision(String revision) { + request.setRevision(revision); + return this; + } + + /** + * @param name The name of the tag + */ + public TagCreateCommandBuilder setName(String name) { + request.setName(name); + return this; + } + + public Tag execute() throws IOException { + return command.create(request); + } + } + + public final class TagDeleteCommandBuilder { + private final TagDeleteRequest request = new TagDeleteRequest(); + + /** + * @param name The name of the tag that should be deleted + */ + public TagDeleteCommandBuilder setName(String name) { + request.setName(name); + return this; + } + + public void execute() throws IOException { + command.delete(request); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java b/scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java new file mode 100644 index 0000000000..d74e0b642e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java @@ -0,0 +1,37 @@ +/* + * 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.api; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TagCreateRequest { + private String revision; + private String name; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.java b/scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.java new file mode 100644 index 0000000000..1e75aaf619 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.java @@ -0,0 +1,36 @@ +/* + * 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.api; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TagDeleteRequest { + private String name; +} 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..4e56cc57f9 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,15 @@ public abstract class RepositoryServiceProvider implements Closeable throw new CommandNotSupportedException(Command.TAGS); } + + /** + * @since 2.11.0 + */ + public TagCommand getTagCommand() + { + throw new CommandNotSupportedException(Command.TAG); + } + /** * Method description * diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.java new file mode 100644 index 0000000000..66d6dd8386 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.java @@ -0,0 +1,39 @@ +/* + * 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 sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.api.TagCreateRequest; + +import java.io.IOException; + +/** + * @since 2.11 + */ +public interface TagCommand { + Tag create(TagCreateRequest request) throws IOException; + void delete(TagDeleteRequest request) throws IOException; +} 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-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java index e3311f2296..21d46c45ee 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java @@ -175,12 +175,12 @@ public class RepositoryAccessITCase { .as("assert tag size") .isNotNull() .size() - .isGreaterThan(0); + .isPositive(); assertThat(response.body().jsonPath().getMap("_embedded.tags.find{it.name=='" + tagName + "'}")) .as("assert tag has attributes for name, revision, date and links") .isNotNull() - .hasSize(4) + .hasSize(5) .containsEntry("name", tagName) .containsEntry("revision", changeset.getId()); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index 6baa48111b..ba7733a5a4 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -55,6 +55,8 @@ import org.eclipse.jgit.util.LfsFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; import sonia.scm.util.HttpUtil; import sonia.scm.util.Util; import sonia.scm.web.GitUserAgentProvider; @@ -63,6 +65,7 @@ import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -73,73 +76,37 @@ import static java.util.Optional.of; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ -public final class GitUtil -{ +public final class GitUtil { private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider(); - - /** Field description */ public static final String REF_HEAD = "HEAD"; - - /** Field description */ public static final String REF_HEAD_PREFIX = "refs/heads/"; - - /** Field description */ public static final String REF_MASTER = "master"; - - /** Field description */ private static final String DIRECTORY_DOTGIT = ".git"; - - /** Field description */ private static final String DIRECTORY_OBJETCS = "objects"; - - /** Field description */ private static final String DIRECTORY_REFS = "refs"; - - /** Field description */ private static final String PREFIX_HEADS = "refs/heads/"; - - /** Field description */ private static final String PREFIX_TAG = "refs/tags/"; - - /** Field description */ private static final String REFSPEC = "+refs/heads/*:refs/remote/scm/%s/*"; - - /** Field description */ private static final String REMOTE_REF = "refs/remote/scm/%s/%s"; - - /** Field description */ private static final int TIMEOUT = 5; - /** Field description */ - private static final String USERAGENT_GIT = "git/"; - - /** the logger for GitUtil */ + /** + * the logger for GitUtil + */ private static final Logger logger = LoggerFactory.getLogger(GitUtil.class); //~--- constructors --------------------------------------------------------- - /** - * Constructs ... - * - */ - private GitUtil() {} + private GitUtil() { + } //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param repo - */ - public static void close(org.eclipse.jgit.lib.Repository repo) - { - if (repo != null) - { + public static void close(org.eclipse.jgit.lib.Repository repo) { + if (repo != null) { repo.close(); } } @@ -147,42 +114,30 @@ public final class GitUtil /** * TODO cache * - * * @param repository * @param revWalk - * - * * @return */ public static Multimap createTagMap(org.eclipse.jgit.lib.Repository repository, - RevWalk revWalk) - { + RevWalk revWalk) { Multimap tags = ArrayListMultimap.create(); Map tagMap = repository.getTags(); - if (tagMap != null) - { - for (Map.Entry e : tagMap.entrySet()) - { - try - { + if (tagMap != null) { + for (Map.Entry e : tagMap.entrySet()) { + try { RevCommit c = getCommit(repository, revWalk, e.getValue()); - if (c != null) - { + if (c != null) { tags.put(c.getId(), e.getKey()); - } - else if (logger.isWarnEnabled()) - { + } else { logger.warn("could not find commit for tag {}", e.getKey()); } - } - catch (IOException ex) - { + } catch (IOException ex) { logger.error("could not read commit for ref", ex); } @@ -193,8 +148,7 @@ public final class GitUtil } public static FetchResult fetch(Git git, File directory, Repository remoteRepository) { - try - { + try { FetchCommand fetch = git.fetch(); fetch.setRemote(directory.getAbsolutePath()); @@ -202,123 +156,63 @@ public final class GitUtil fetch.setTimeout((int) TimeUnit.MINUTES.toSeconds(TIMEOUT)); return fetch.call(); - } - catch (GitAPIException ex) - { + } catch (GitAPIException ex) { throw new InternalRepositoryException(ContextEntry.ContextBuilder.entity("Remote", directory.toString()).in(remoteRepository), "could not fetch", ex); } } - /** - * Method description - * - * - * @param directory - * - * @return - * - * @throws IOException - */ public static org.eclipse.jgit.lib.Repository open(File directory) - throws IOException - { + throws IOException { FS fs = FS.DETECTED; FileRepositoryBuilder builder = new FileRepositoryBuilder(); builder.setFS(fs); - if (isGitDirectory(fs, directory)) - { + if (isGitDirectory(fs, directory)) { // bare repository builder.setGitDir(directory).setBare(); - } - else - { + } else { builder.setWorkTree(directory); } return builder.build(); } - /** - * Method description - * - * - * @param formatter - */ - public static void release(DiffFormatter formatter) - { - if (formatter != null) - { + public static void release(DiffFormatter formatter) { + if (formatter != null) { formatter.close(); } } - /** - * Method description - * - * - * @param walk - */ - public static void release(TreeWalk walk) - { - if (walk != null) - { + public static void release(TreeWalk walk) { + if (walk != null) { walk.close(); } } - /** - * Method description - * - * - * @param walk - */ - public static void release(RevWalk walk) - { - if (walk != null) - { + public static void release(RevWalk walk) { + if (walk != null) { walk.close(); } } //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @param ref - * - * @return - */ - public static String getBranch(Ref ref) - { + public static String getBranch(Ref ref) { String branch = null; - if (ref != null) - { + if (ref != null) { branch = getBranch(ref.getName()); } return branch; } - /** - * Method description - * - * - * @param name - * - * @return - */ - public static String getBranch(String name) - { + public static String getBranch(String name) { String branch = null; - if (Util.isNotEmpty(name) && name.startsWith(PREFIX_HEADS)) - { + if (Util.isNotEmpty(name) && name.startsWith(PREFIX_HEADS)) { branch = name.substring(PREFIX_HEADS.length()); } @@ -329,18 +223,15 @@ public final class GitUtil * Returns {@code true} if the provided reference name is a branch name. * * @param refName reference name - * * @return {@code true} if the name is a branch name - * * @since 1.50 */ - public static boolean isBranch(String refName) - { + public static boolean isBranch(String refName) { return Strings.nullToEmpty(refName).startsWith(PREFIX_HEADS); } public static Ref getBranchIdOrCurrentHead(org.eclipse.jgit.lib.Repository gitRepository, String requestedBranch) throws IOException { - if ( Strings.isNullOrEmpty(requestedBranch) ) { + if (Strings.isNullOrEmpty(requestedBranch)) { logger.trace("no default branch configured, use repository head as default"); Optional repositoryHeadRef = GitUtil.getRepositoryHeadRef(gitRepository); return repositoryHeadRef.orElse(null); @@ -352,37 +243,28 @@ public final class GitUtil /** * Method description * - * * @param repo * @param branchName - * * @return - * * @throws IOException */ public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo, - String branchName) - throws IOException - { + String branchName) + throws IOException { Ref ref = null; - if (!branchName.startsWith(REF_HEAD)) - { + if (!branchName.startsWith(REF_HEAD)) { branchName = PREFIX_HEADS.concat(branchName); } checkBranchName(repo, branchName); - try - { + try { ref = repo.findRef(branchName); - if (ref == null) - { + if (ref == null) { logger.warn("could not find branch for {}", branchName); } - } - catch (IOException ex) - { + } catch (IOException ex) { logger.warn("error occured during resolve of branch id", ex); } @@ -415,33 +297,21 @@ public final class GitUtil } /** - * Method description - * - * - * @param repository - * @param revWalk - * @param ref - * - * @return - * - * @throws IOException + * Returns the commit for the given ref. + * If the given ref is for a tag, the commit that this tag belongs to is returned instead. */ public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository, - RevWalk revWalk, Ref ref) - throws IOException - { + RevWalk revWalk, Ref ref) + throws IOException { RevCommit commit = null; ObjectId id = ref.getPeeledObjectId(); - if (id == null) - { + if (id == null) { id = ref.getObjectId(); } - if (id != null) - { - if (revWalk == null) - { + if (id != null) { + if (revWalk == null) { revWalk = new RevWalk(repository); } @@ -451,16 +321,30 @@ public final class GitUtil return commit; } + public static RevTag getTag(org.eclipse.jgit.lib.Repository repository, + RevWalk revWalk, Ref ref) + throws IOException { + RevTag tag = null; + ObjectId id = ref.getObjectId(); + + if (id != null) { + if (revWalk == null) { + revWalk = new RevWalk(repository); + } + + tag = revWalk.parseTag(id); + } + + return tag; + } + /** * Method description * - * * @param commit - * * @return */ - public static long getCommitTime(RevCommit commit) - { + public static long getCommitTime(RevCommit commit) { long date = commit.getCommitTime(); date = date * 1000; @@ -471,17 +355,13 @@ public final class GitUtil /** * Method description * - * * @param objectId - * * @return */ - public static String getId(AnyObjectId objectId) - { + public static String getId(AnyObjectId objectId) { String id = Util.EMPTY_STRING; - if (objectId != null) - { + if (objectId != null) { id = objectId.name(); } @@ -491,44 +371,27 @@ public final class GitUtil /** * Method description * - * * @param repository * @param id - * * @return - * * @throws IOException */ public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository, - ObjectId id) - throws IOException - { + ObjectId id) + throws IOException { Ref ref = null; - RevWalk walk = null; - - try - { - walk = new RevWalk(repository); + try (RevWalk walk = new RevWalk(repository)) { RevCommit commit = walk.parseCommit(id); - for (Map.Entry e : repository.getAllRefs().entrySet()) - { - if (e.getKey().startsWith(Constants.R_HEADS)) - { - if (walk.isMergedInto(commit, - walk.parseCommit(e.getValue().getObjectId()))) - { - ref = e.getValue(); - } + for (Map.Entry e : repository.getAllRefs().entrySet()) { + if (e.getKey().startsWith(Constants.R_HEADS) && walk.isMergedInto(commit, + walk.parseCommit(e.getValue().getObjectId()))) { + ref = e.getValue(); } } } - finally - { - release(walk); - } return ref; } @@ -580,26 +443,19 @@ public final class GitUtil /** * Method description * - * * @param repo * @param revision - * * @return - * * @throws IOException */ public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo, - String revision) - throws IOException - { + String revision) + throws IOException { ObjectId revId; - if (Util.isNotEmpty(revision)) - { + if (Util.isNotEmpty(revision)) { revId = repo.resolve(revision); - } - else - { + } else { revId = getRepositoryHead(repo); } @@ -609,34 +465,27 @@ public final class GitUtil /** * Method description * - * * @param repository * @param localBranch - * * @return */ public static String getScmRemoteRefName(Repository repository, - Ref localBranch) - { + Ref localBranch) { return getScmRemoteRefName(repository, localBranch.getName()); } /** * Method description * - * * @param repository * @param localBranch - * * @return */ public static String getScmRemoteRefName(Repository repository, - String localBranch) - { + String localBranch) { String branch = localBranch; - if (localBranch.startsWith(REF_HEAD_PREFIX)) - { + if (localBranch.startsWith(REF_HEAD_PREFIX)) { branch = localBranch.substring(REF_HEAD_PREFIX.length()); } @@ -647,16 +496,12 @@ public final class GitUtil * Returns the name of the tag or {@code null} if the the ref is not a tag. * * @param refName ref name - * * @return name of tag or {@link null} - * * @since 1.50 */ - public static String getTagName(String refName) - { + public static String getTagName(String refName) { String tagName = null; - if (refName.startsWith(PREFIX_TAG)) - { + if (refName.startsWith(PREFIX_TAG)) { tagName = refName.substring(PREFIX_TAG.length()); } @@ -666,91 +511,112 @@ public final class GitUtil /** * Method description * - * * @param ref - * * @return */ - public static String getTagName(Ref ref) - { + public static String getTagName(Ref ref) { String name = ref.getName(); - if (name.startsWith(PREFIX_TAG)) - { + if (name.startsWith(PREFIX_TAG)) { name = name.substring(PREFIX_TAG.length()); } return name; } + private static final String GPG_HEADER = "-----BEGIN PGP SIGNATURE-----"; + + public static Optional getTagSignature(RevObject revObject, GPG gpg, RevWalk revWalk) throws IOException { + if (revObject instanceof RevTag) { + final byte[] messageBytes = revWalk.getObjectReader().open(revObject.getId()).getBytes(); + final String message = new String(messageBytes); + final int signatureStartIndex = message.indexOf(GPG_HEADER); + if (signatureStartIndex < 0) { + return Optional.empty(); + } + + final String signature = message.substring(signatureStartIndex); + + String publicKeyId = gpg.findPublicKeyId(signature.getBytes()); + if (Strings.isNullOrEmpty(publicKeyId)) { + // key not found + return Optional.of(new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet())); + } + + Optional publicKeyById = gpg.findPublicKey(publicKeyId); + if (!publicKeyById.isPresent()) { + // key not found + return Optional.of(new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet())); + } + + PublicKey publicKey = publicKeyById.get(); + + String rawMessage = message.substring(0, signatureStartIndex); + boolean verified = publicKey.verify(rawMessage.getBytes(), signature.getBytes()); + return Optional.of(new Signature( + publicKeyId, + "gpg", + verified ? SignatureStatus.VERIFIED : SignatureStatus.INVALID, + publicKey.getOwner().orElse(null), + publicKey.getContacts() + )); + } + return Optional.empty(); + } + /** * Returns true if the request comes from a git client. * - * * @param request servlet request - * * @return true if the client is git */ - public static boolean isGitClient(HttpServletRequest request) - { + public static boolean isGitClient(HttpServletRequest request) { return GIT_USER_AGENT_PROVIDER.parseUserAgent(request.getHeader(HttpUtil.HEADER_USERAGENT)) != null; } /** * Method description * - * * @param dir - * * @return */ - public static boolean isGitDirectory(File dir) - { + public static boolean isGitDirectory(File dir) { return isGitDirectory(FS.DETECTED, dir); } /** * Method description * - * * @param fs * @param dir - * * @return */ - public static boolean isGitDirectory(FS fs, File dir) - { + public static boolean isGitDirectory(FS fs, File dir) { //J- return fs.resolve(dir, DIRECTORY_OBJETCS).exists() && fs.resolve(dir, DIRECTORY_REFS).exists() - &&!fs.resolve(dir, DIRECTORY_DOTGIT).exists(); + && !fs.resolve(dir, DIRECTORY_DOTGIT).exists(); //J+ } /** * Method description * - * * @param ref - * * @return */ - public static boolean isHead(String ref) - { + public static boolean isHead(String ref) { return ref.startsWith(REF_HEAD_PREFIX); } /** * Method description * - * * @param id - * * @return */ - public static boolean isValidObjectId(ObjectId id) - { - return (id != null) &&!id.equals(ObjectId.zeroId()); + public static boolean isValidObjectId(ObjectId id) { + return (id != null) && !id.equals(ObjectId.zeroId()); } /** @@ -792,25 +658,20 @@ public final class GitUtil /** * Method description * - * * @param repo * @param branchName - * * @throws IOException */ @VisibleForTesting static void checkBranchName(org.eclipse.jgit.lib.Repository repo, - String branchName) - throws IOException - { - if (branchName.contains("..")) - { + String branchName) + throws IOException { + if (branchName.contains("..")) { File repoDirectory = repo.getDirectory(); File branchFile = new File(repoDirectory, branchName); if (!branchFile.getCanonicalPath().startsWith( - repoDirectory.getCanonicalPath())) - { + repoDirectory.getCanonicalPath())) { logger.error( "branch \"{}\" is outside of the repository. It looks like path traversal attack", branchName); @@ -824,13 +685,10 @@ public final class GitUtil /** * Method description * - * * @param repository - * * @return */ - private static RefSpec createRefSpec(Repository repository) - { + private static RefSpec createRefSpec(Repository repository) { return new RefSpec(String.format(REFSPEC, repository.getId())); } } 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 fae69a47cf..d2df924e7d 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 @@ -47,6 +47,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.DIFF, Command.DIFF_RESULT, Command.LOG, + Command.TAG, Command.TAGS, Command.BRANCH, Command.BRANCHES, @@ -142,7 +143,12 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider @Override public TagsCommand getTagsCommand() { - return new GitTagsCommand(context); + return commandInjector.getInstance(GitTagsCommand.class); + } + + @Override + public TagCommand getTagCommand() { + return commandInjector.getInstance(GitTagCommand.class); } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java new file mode 100644 index 0000000000..8e462e463a --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java @@ -0,0 +1,214 @@ +/* + * 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.google.common.base.Strings; +import org.apache.shiro.SecurityUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevWalk; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.PostReceiveRepositoryHookEvent; +import sonia.scm.repository.PreReceiveRepositoryHookEvent; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryHookType; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.HookContext; +import sonia.scm.repository.api.HookContextFactory; +import sonia.scm.repository.api.HookFeature; +import sonia.scm.repository.api.HookTagProvider; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.user.User; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class GitTagCommand extends AbstractGitCommand implements TagCommand { + private final HookContextFactory hookContextFactory; + private final ScmEventBus eventBus; + + @Inject + GitTagCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus) { + super(context); + this.hookContextFactory = hookContextFactory; + this.eventBus = eventBus; + } + + @Override + public Tag create(TagCreateRequest request) { + final String name = request.getName(); + final String revision = request.getRevision(); + + if (Strings.isNullOrEmpty(revision)) { + throw new IllegalArgumentException("Revision is required"); + } + + if (Strings.isNullOrEmpty(name)) { + throw new IllegalArgumentException("Name is required"); + } + + try (Git git = new Git(context.open())) { + + RevObject revObject; + Long tagTime; + + ObjectId taggedCommitObjectId = git.getRepository().resolve(revision); + + if (taggedCommitObjectId == null) { + throw notFound(entity("revision", revision).in(repository)); + } + + try (RevWalk walk = new RevWalk(git.getRepository())) { + revObject = walk.parseAny(taggedCommitObjectId); + tagTime = GitUtil.getTagTime(walk, taggedCommitObjectId); + } + + Tag tag = new Tag(name, revision, tagTime); + + RepositoryHookEvent hookEvent = createTagHookEvent(TagHookContextProvider.createHookEvent(tag)); + eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); + + User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class); + PersonIdent taggerIdent = new PersonIdent(user.getDisplayName(), user.getMail()); + + git.tag() + .setObjectId(revObject) + .setTagger(taggerIdent) + .setName(name) + .call(); + + eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); + + return tag; + } catch (IOException | GitAPIException ex) { + throw new InternalRepositoryException(repository, "could not create tag " + name + " for revision " + revision, ex); + } + } + + @Override + public void delete(TagDeleteRequest request) { + String name = request.getName(); + + if (Strings.isNullOrEmpty(name)) { + throw new IllegalArgumentException("Name is required"); + } + + try (Git git = new Git(context.open())) { + final Repository repository = git.getRepository(); + Optional tagRef = findTagRef(git, name); + Tag tag; + + // Deleting a non-existent tag is a valid action and simply succeeds without + // anything happening. + if (!tagRef.isPresent()) { + return; + } + + try (RevWalk walk = new RevWalk(repository)) { + final RevCommit commit = GitUtil.getCommit(repository, walk, tagRef.get()); + Long tagTime = GitUtil.getTagTime(walk, tagRef.get().getObjectId()); + tag = new Tag(name, commit.name(), tagTime); + } + + RepositoryHookEvent hookEvent = createTagHookEvent(TagHookContextProvider.deleteHookEvent(tag)); + eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); + git.tagDelete().setTags(name).call(); + eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); + } catch (GitAPIException | IOException e) { + throw new InternalRepositoryException(repository, "could not delete tag " + name, e); + } + } + + private Optional findTagRef(Git git, String name) throws GitAPIException { + final String tagRef = "refs/tags/" + name; + return git.tagList().call().stream().filter(it -> it.getName().equals(tagRef)).findAny(); + } + + private RepositoryHookEvent createTagHookEvent(TagHookContextProvider hookEvent) { + HookContext context = hookContextFactory.createContext(hookEvent, this.context.getRepository()); + return new RepositoryHookEvent(context, this.context.getRepository(), RepositoryHookType.PRE_RECEIVE); + } + + private static class TagHookContextProvider extends HookContextProvider { + private final List newTags; + private final List deletedTags; + + private TagHookContextProvider(List newTags, List deletedTags) { + this.newTags = newTags; + this.deletedTags = deletedTags; + } + + static TagHookContextProvider createHookEvent(Tag newTag) { + return new TagHookContextProvider(singletonList(newTag), emptyList()); + } + + static TagHookContextProvider deleteHookEvent(Tag deletedTag) { + return new TagHookContextProvider(emptyList(), singletonList(deletedTag)); + } + + @Override + public Set getSupportedFeatures() { + return singleton(HookFeature.TAG_PROVIDER); + } + + @Override + public HookTagProvider getTagProvider() { + return new HookTagProvider() { + @Override + public List getCreatedTags() { + return newTags; + } + + @Override + public List getDeletedTags() { + return deletedTags; + } + }; + } + + @Override + public HookChangesetProvider getChangesetProvider() { + return r -> new HookChangesetResponse(emptyList()); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java index 60b1105547..f4b9358265 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java @@ -30,15 +30,21 @@ import com.google.common.base.Function; import com.google.common.collect.Lists; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevTag; import org.eclipse.jgit.revwalk.RevWalk; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Tag; +import sonia.scm.security.GPG; +import javax.inject.Inject; import java.io.IOException; import java.util.List; @@ -49,32 +55,34 @@ import java.util.List; */ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { + private final GPG gpg; + /** * Constructs ... * * @param context */ - public GitTagsCommand(GitContext context) { + @Inject + public GitTagsCommand(GitContext context, GPG gpp) { super(context); + this.gpg = gpp; } //~--- get methods ---------------------------------------------------------- @Override public List getTags() throws IOException { - List tags = null; + List tags; RevWalk revWalk = null; - try { - final Git git = new Git(open()); - + try (Git git = new Git(open())) { revWalk = new RevWalk(git.getRepository()); List tagList = git.tagList().call(); tags = Lists.transform(tagList, - new TransformFuntion(git.getRepository(), revWalk)); + new TransformFunction(git.getRepository(), revWalk, gpg)); } catch (GitAPIException ex) { throw new InternalRepositoryException(repository, "could not read tags from repository", ex); } finally { @@ -92,26 +100,27 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { * @author Enter your name here... * @version Enter version here..., 12/07/06 */ - private static class TransformFuntion implements Function { + private static class TransformFunction implements Function { /** * the logger for TransformFuntion */ private static final Logger logger = - LoggerFactory.getLogger(TransformFuntion.class); + LoggerFactory.getLogger(TransformFunction.class); //~--- constructors ------------------------------------------------------- /** * Constructs ... - * - * @param repository + * @param repository * @param revWalk */ - public TransformFuntion(org.eclipse.jgit.lib.Repository repository, - RevWalk revWalk) { + public TransformFunction(Repository repository, + RevWalk revWalk, + GPG gpg) { this.repository = repository; this.revWalk = revWalk; + this.gpg = gpg; } //~--- methods ------------------------------------------------------------ @@ -127,14 +136,17 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { Tag tag = null; try { - RevObject revObject = GitUtil.getCommit(repository, revWalk, ref); - - if (revObject != null) { + RevCommit revCommit = GitUtil.getCommit(repository, revWalk, ref); + if (revCommit != null) { String name = GitUtil.getTagName(ref); - - tag = new Tag(name, revObject.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId())); + tag = new Tag(name, revCommit.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId())); + RevObject revObject = revWalk.parseAny(ref.getObjectId()); + if (revObject.getType() == Constants.OBJ_TAG) { + RevTag revTag = (RevTag) revObject; + GitUtil.getTagSignature(revTag, gpg, revWalk) + .ifPresent(tag::addSignature); + } } - } catch (IOException ex) { logger.error("could not get commit for tag", ex); } @@ -147,11 +159,12 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { /** * Field description */ - private org.eclipse.jgit.lib.Repository repository; + private final org.eclipse.jgit.lib.Repository repository; /** * Field description */ - private RevWalk revWalk; + private final RevWalk revWalk; + private final GPG gpg; } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java index 0df9332cfe..1fba957da5 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java @@ -32,7 +32,6 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Branch; -import sonia.scm.repository.BranchCreatedEvent; import sonia.scm.repository.PostReceiveRepositoryHookEvent; import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.api.BranchRequest; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java new file mode 100644 index 0000000000..77178a3e4b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java @@ -0,0 +1,170 @@ +/* + * 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.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.DefaultSecurityManager; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.eclipse.jgit.lib.GpgSigner; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.GitTestHelper; +import sonia.scm.repository.PostReceiveRepositoryHookEvent; +import sonia.scm.repository.PreReceiveRepositoryHookEvent; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.HookContext; +import sonia.scm.repository.api.HookContextFactory; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.security.GPG; +import sonia.scm.util.MockUtil; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class GitTagCommandTest extends AbstractGitCommandTestBase { + + @Mock + private GPG gpg; + + @Mock + private HookContextFactory hookContextFactory; + + @Mock + private ScmEventBus eventBus; + + private Subject subject; + + @Before + public void setSigner() { + GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + } + + @Before + public void bindThreadContext() { + SecurityUtils.setSecurityManager(new DefaultSecurityManager()); + subject = MockUtil.createUserSubject(SecurityUtils.getSecurityManager()); + ThreadContext.bind(subject); + } + + @After + public void unbindThreadContext() { + ThreadContext.unbindSubject(); + ThreadContext.unbindSecurityManager(); + } + + @Test + public void shouldCreateATag() throws IOException { + createCommand().create(new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "newtag")); + Optional optionalTag = findTag(createContext(), "newtag"); + assertThat(optionalTag).isNotEmpty(); + final Tag tag = optionalTag.get(); + assertThat(tag.getName()).isEqualTo("newtag"); + assertThat(tag.getRevision()).isEqualTo("592d797cd36432e591416e8b2b98154f4f163411"); + } + + @Test + public void shouldPostCreateEvent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + doNothing().when(eventBus).post(captor.capture()); + when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext); + + createCommand().create(new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "newtag")); + + List events = captor.getAllValues(); + assertThat(events.get(0)).isInstanceOf(PreReceiveRepositoryHookEvent.class); + assertThat(events.get(1)).isInstanceOf(PostReceiveRepositoryHookEvent.class); + + PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0); + assertThat(event.getContext().getTagProvider().getCreatedTags().get(0).getName()).isEqualTo("newtag"); + assertThat(event.getContext().getTagProvider().getDeletedTags()).isEmpty(); + } + + @Test + public void shouldDeleteATag() throws IOException { + final GitContext context = createContext(); + Optional tag = findTag(context, "test-tag"); + assertThat(tag).isNotEmpty(); + + createCommand().delete(new TagDeleteRequest("test-tag")); + + tag = findTag(context, "test-tag"); + assertThat(tag).isEmpty(); + } + + @Test + public void shouldPostDeleteEvent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + doNothing().when(eventBus).post(captor.capture()); + when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext); + + createCommand().delete(new TagDeleteRequest("test-tag")); + + List events = captor.getAllValues(); + assertThat(events.get(0)).isInstanceOf(PreReceiveRepositoryHookEvent.class); + assertThat(events.get(1)).isInstanceOf(PostReceiveRepositoryHookEvent.class); + + PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0); + assertThat(event.getContext().getTagProvider().getCreatedTags()).isEmpty(); + final Tag deletedTag = event.getContext().getTagProvider().getDeletedTags().get(0); + assertThat(deletedTag.getName()).isEqualTo("test-tag"); + assertThat(deletedTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); + } + + private GitTagCommand createCommand() { + return new GitTagCommand(createContext(), hookContextFactory, eventBus); + } + + private List readTags(GitContext context) throws IOException { + return new GitTagsCommand(context, gpg).getTags(); + } + + private Optional findTag(GitContext context, String name) throws IOException { + List tags = readTags(context); + return tags.stream().filter(t -> name.equals(t.getName())).findFirst(); + } + + private HookContext createMockedContext(InvocationOnMock invocation) { + HookContext mock = mock(HookContext.class); + when(mock.getTagProvider()).thenReturn(((HookContextProvider) invocation.getArgument(0)).getTagProvider()); + return mock; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java index 9112aa3133..fa20318e15 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java @@ -24,48 +24,86 @@ package sonia.scm.repository.spi; -import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.repository.SignatureStatus; import sonia.scm.repository.Tag; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; import java.io.IOException; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") +@RunWith(MockitoJUnitRunner.class) public class GitTagsCommandTest extends AbstractGitCommandTestBase { - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Mock + GPG gpg; - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); - - @Rule - public ShiroRule shiro = new ShiroRule(); + @Mock + PublicKey publicKey; @Test public void shouldGetDatesCorrectly() throws IOException { final GitContext gitContext = createContext(); - final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext); + final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg); final List tags = tagsCommand.getTags(); - assertThat(tags).hasSize(2); + assertThat(tags).hasSize(3); Tag annotatedTag = tags.get(0); assertThat(annotatedTag.getName()).isEqualTo("1.0.0"); assertThat(annotatedTag.getDate()).contains(1598348105000L); // Annotated - Take tag date assertThat(annotatedTag.getRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); - Tag lightweightTag = tags.get(1); + Tag lightweightTag = tags.get(2); assertThat(lightweightTag.getName()).isEqualTo("test-tag"); assertThat(lightweightTag.getDate()).contains(1339416344000L); // Lightweight - Take commit date assertThat(lightweightTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); } + @Test + public void shouldGetSignatures() throws IOException { + when(gpg.findPublicKeyId(ArgumentMatchers.any())).thenReturn("2BA27721F113C005CC16F06BAE63EFBC49F140CF"); + when(gpg.findPublicKey("2BA27721F113C005CC16F06BAE63EFBC49F140CF")).thenReturn(Optional.of(publicKey)); + String signature = "-----BEGIN PGP SIGNATURE-----\n" + + "\n" + + "iQEzBAABCgAdFiEEK6J3IfETwAXMFvBrrmPvvEnxQM8FAl+9acoACgkQrmPvvEnx\n" + + "QM9abwgAnGP+Y/Ijli+PAsimfOmZQWYepjptoOv9m7i3bnHv8V+Qg6cm51I3E0YV\n" + + "R2QaxxzW9PgS4hcES+L1qs8Lwo18RurF469eZEmNb8DcUFJ3sEWeHlIl5wZNNo/v\n" + + "jJm0d9LNcSmtAIiQ8eDMoGdFXJzHewGickLOSsQGmfZgZus4Qlsh7r3BZTI1Zwd/\n" + + "6jaBFctX13FuepCTxq2SjEfRaQHIYkyFQq2o6mjL5S2qfYJ/S//gcCCzxllQrisF\n" + + "5fRW3LzLI4eXFH0vua7+UzNS2Rwpifg2OENJA/Kn+3R36LWEGxFK9pNqjVPRAcQj\n" + + "1vSkcjK26RqhAqCjNLSagM8ATZrh+g==\n" + + "=kUKm\n" + + "-----END PGP SIGNATURE-----\n"; + String signedContent = "object 592d797cd36432e591416e8b2b98154f4f163411\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger Arthur Dent 1606248906 +0100\n" + + "\n" + + "this tag is signed\n"; + when(publicKey.verify(signedContent.getBytes(), signature.getBytes())).thenReturn(true); + + final GitContext gitContext = createContext(); + final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg); + final List tags = tagsCommand.getTags(); + + assertThat(tags).hasSize(3); + + Tag signedTag = tags.get(1); + assertThat(signedTag.getSignatures()).isNotEmpty(); + assertThat(signedTag.getSignatures().get(0).getStatus()).isEqualTo(SignatureStatus.VERIFIED); + } + @Override protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-spi-test-tags.zip"; diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip index 05b2f0f7ca..20f8169ded 100644 Binary files a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip differ diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractCommand.java index f7d33b8973..f252bf3184 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractCommand.java @@ -35,60 +35,28 @@ import sonia.scm.repository.Repository; public class AbstractCommand { - /** - * Constructs ... - * - * @param context - * - */ + protected final HgCommandContext context; + protected final Repository repository; + public AbstractCommand(HgCommandContext context) { this.context = context; this.repository = context.getScmRepository(); } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ public com.aragost.javahg.Repository open() { return context.open(); } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ public HgCommandContext getContext() { return context; } - - /** - * Method description - * - * - * @return - */ public Repository getRepository() { return repository; } - //~--- fields --------------------------------------------------------------- - /** Field description */ - private HgCommandContext context; - - /** Field description */ - private Repository repository; } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractWorkingCopyCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractWorkingCopyCommand.java new file mode 100644 index 0000000000..48bd3beb12 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractWorkingCopyCommand.java @@ -0,0 +1,64 @@ +/* + * 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.Changeset; +import com.aragost.javahg.Repository; +import com.aragost.javahg.commands.ExecutionException; +import com.aragost.javahg.commands.PullCommand; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.work.WorkingCopy; + +import java.io.IOException; +import java.util.List; +import java.util.regex.Pattern; + +public class AbstractWorkingCopyCommand extends AbstractCommand { + static final Pattern HG_MESSAGE_PATTERN = Pattern.compile(".*\\[SCM\\](?: Error:)? (.*)"); + + protected final HgWorkingCopyFactory workingCopyFactory; + + public AbstractWorkingCopyCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) + { + super(context); + this.workingCopyFactory = workingCopyFactory; + } + + protected List pullChangesIntoCentralRepository(WorkingCopy workingCopy, String branch) { + try { + com.aragost.javahg.commands.PullCommand pullCommand = PullCommand.on(workingCopy.getCentralRepository()); + workingCopyFactory.configure(pullCommand); + return pullCommand.execute(workingCopy.getDirectory().getAbsolutePath()); + } catch (ExecutionException e) { + throw IntegrateChangesFromWorkdirException + .withPattern(HG_MESSAGE_PATTERN) + .forMessage(context.getScmRepository(), e.getMessage()); + } catch (IOException e) { + throw new InternalRepositoryException(getRepository(), + String.format("Could not pull changes '%s' into central repository", branch), + e); + } + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java index 7319a312ff..ed50580b72 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java @@ -26,7 +26,6 @@ package sonia.scm.repository.spi; import com.aragost.javahg.Changeset; import com.aragost.javahg.commands.CommitCommand; -import com.aragost.javahg.commands.PullCommand; import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,21 +36,16 @@ import sonia.scm.repository.api.BranchRequest; import sonia.scm.repository.work.WorkingCopy; import sonia.scm.user.User; -import java.io.IOException; - /** * Mercurial implementation of the {@link BranchCommand}. * Note that this creates an empty commit to "persist" the new branch. */ -public class HgBranchCommand extends AbstractCommand implements BranchCommand { +public class HgBranchCommand extends AbstractWorkingCopyCommand implements BranchCommand { private static final Logger LOG = LoggerFactory.getLogger(HgBranchCommand.class); - private final HgWorkingCopyFactory workingCopyFactory; - HgBranchCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) { - super(context); - this.workingCopyFactory = workingCopyFactory; + super(context, workingCopyFactory); } @Override @@ -103,15 +97,4 @@ public class HgBranchCommand extends AbstractCommand implements BranchCommand { .execute(); } - private void pullChangesIntoCentralRepository(WorkingCopy workingCopy, String branch) { - try { - PullCommand pullCommand = PullCommand.on(workingCopy.getCentralRepository()); - workingCopyFactory.configure(pullCommand); - pullCommand.execute(workingCopy.getDirectory().getAbsolutePath()); - } catch (IOException e) { - throw new InternalRepositoryException(getRepository(), - String.format("Could not pull changes '%s' into central repository", branch), - e); - } - } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java index aaea12eeac..5a34f341f8 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java @@ -28,7 +28,6 @@ import com.aragost.javahg.Changeset; import com.aragost.javahg.Repository; import com.aragost.javahg.commands.CommitCommand; import com.aragost.javahg.commands.ExecutionException; -import com.aragost.javahg.commands.PullCommand; import com.aragost.javahg.commands.RemoveCommand; import com.aragost.javahg.commands.StatusCommand; import org.slf4j.Logger; @@ -41,20 +40,17 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.util.List; -import java.util.regex.Pattern; + +import static sonia.scm.repository.spi.UserFormatter.getUserStringFor; @SuppressWarnings("java:S3252") // it is ok for javahg classes to access static method of subtype -public class HgModifyCommand implements ModifyCommand { +public class HgModifyCommand extends AbstractWorkingCopyCommand implements ModifyCommand { private static final Logger LOG = LoggerFactory.getLogger(HgModifyCommand.class); - static final Pattern HG_MESSAGE_PATTERN = Pattern.compile(".*\\[SCM\\](?: Error:)? (.*)"); - private final HgCommandContext context; - private final HgWorkingCopyFactory workingCopyFactory; public HgModifyCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) { - this.context = context; - this.workingCopyFactory = workingCopyFactory; + super(context, workingCopyFactory); } @Override @@ -110,10 +106,10 @@ public class HgModifyCommand implements ModifyCommand { LOG.trace("commit changes in working copy"); CommitCommand.on(workingRepository) - .user(String.format("%s <%s>", request.getAuthor().getName(), request.getAuthor().getMail())) + .user(getUserStringFor(request.getAuthor())) .message(request.getCommitMessage()).execute(); - List execute = pullModifyChangesToCentralRepository(request, workingCopy); + List execute = pullChangesIntoCentralRepository(workingCopy, request.getBranch()); String node = execute.get(0).getNode(); LOG.debug("successfully pulled changes from working copy, new node {}", node); @@ -124,24 +120,7 @@ public class HgModifyCommand implements ModifyCommand { } } - private List pullModifyChangesToCentralRepository(ModifyCommandRequest request, WorkingCopy workingCopy) { - LOG.trace("pull changes from working copy"); - try { - com.aragost.javahg.commands.PullCommand pullCommand = PullCommand.on(workingCopy.getCentralRepository()); - workingCopyFactory.configure(pullCommand); - return pullCommand.execute(workingCopy.getDirectory().getAbsolutePath()); - } catch (ExecutionException e) { - throw IntegrateChangesFromWorkdirException - .withPattern(HG_MESSAGE_PATTERN) - .forMessage(context.getScmRepository(), e.getMessage()); - } catch (IOException e) { - throw new InternalRepositoryException(context.getScmRepository(), - String.format("Could not pull modify changes from working copy to central repository for branch %s", request.getBranch()), - e); - } - } - - private String throwInternalRepositoryException(String message, Exception e) { + private void throwInternalRepositoryException(String message, Exception e) { throw new InternalRepositoryException(context.getScmRepository(), message, e); } } 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 0188078b77..b70c2b1b69 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 @@ -49,6 +49,7 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { Command.DIFF, Command.LOG, Command.TAGS, + Command.TAG, Command.BRANCH, Command.BRANCHES, Command.INCOMING, @@ -261,4 +262,9 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { return new HgTagsCommand(context); } + @Override + public TagCommand getTagCommand() { + return new HgTagCommand(context, handler.getWorkingCopyFactory()); + } + } 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..13459356fd --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagCommand.java @@ -0,0 +1,74 @@ +/* + * 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 org.apache.shiro.SecurityUtils; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.work.WorkingCopy; +import sonia.scm.user.User; + +import static sonia.scm.repository.spi.UserFormatter.getUserStringFor; + +public class HgTagCommand extends AbstractWorkingCopyCommand implements TagCommand { + + public static final String DEFAULT_BRANCH_NAME = "default"; + + public HgTagCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) { + super(context, workingCopyFactory); + } + + @Override + public Tag create(TagCreateRequest request) { + try (WorkingCopy workingCopy = workingCopyFactory.createWorkingCopy(getContext(), DEFAULT_BRANCH_NAME)) { + Repository repository = getContext().open(); + String rev = request.getRevision(); + if (Strings.isNullOrEmpty(rev)) { + rev = repository.tip().getNode(); + } + com.aragost.javahg.commands.TagCommand.on(workingCopy.getWorkingRepository()) + .rev(rev) + .user(getUserStringFor(SecurityUtils.getSubject().getPrincipals().oneByType(User.class))) + .execute(request.getName()); + pullChangesIntoCentralRepository(workingCopy, DEFAULT_BRANCH_NAME); + return new Tag(request.getName(), rev); + } + } + + @Override + public void delete(TagDeleteRequest request) { + try (WorkingCopy workingCopy = workingCopyFactory.createWorkingCopy(getContext(), DEFAULT_BRANCH_NAME)) { + com.aragost.javahg.commands.TagCommand.on(workingCopy.getWorkingRepository()) + .user(getUserStringFor(SecurityUtils.getSubject().getPrincipals().oneByType(User.class))) + .remove() + .execute(request.getName()); + + pullChangesIntoCentralRepository(workingCopy, DEFAULT_BRANCH_NAME); + } + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagsCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagsCommand.java index b0fb323bab..c48fb04445 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagsCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgTagsCommand.java @@ -39,6 +39,8 @@ import java.util.List; */ public class HgTagsCommand extends AbstractCommand implements TagsCommand { + public static final String DEFAULT_TAG_NAME = "tip"; + /** * Constructs ... * @@ -99,7 +101,7 @@ public class HgTagsCommand extends AbstractCommand implements TagsCommand { if ((f != null) && !Strings.isNullOrEmpty(f.getName()) && (f.getChangeset() != null)) { - t = new Tag(f.getName(), f.getChangeset().getNode(), f.getChangeset().getTimestamp().getDate().getTime()); + t = new Tag(f.getName(), f.getChangeset().getNode(), f.getChangeset().getTimestamp().getDate().getTime(), !f.getName().equals(DEFAULT_TAG_NAME)); } return t; diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/UserFormatter.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/UserFormatter.java new file mode 100644 index 0000000000..e48a62f7e0 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/UserFormatter.java @@ -0,0 +1,41 @@ +/* + * 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 sonia.scm.repository.Person; +import sonia.scm.user.User; + +final class UserFormatter { + private UserFormatter() { + } + + static String getUserStringFor(User user) { + return getUserStringFor(new Person(user.getName(), user.getMail())); + } + + static String getUserStringFor(Person person) { + return String.format("%s <%s>", person.getName(), person.getMail()); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java index 168d741bcb..e517a5f4a9 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java @@ -184,14 +184,14 @@ public class HgModifyCommandTest extends AbstractHgCommandTestBase { @Test public void shouldExtractSimpleMessage() { - Matcher matcher = HgModifyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] This is a simple message"); + Matcher matcher = AbstractWorkingCopyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] This is a simple message"); matcher.matches(); assertThat(matcher.group(1)).isEqualTo("This is a simple message"); } @Test public void shouldExtractErrorMessage() { - Matcher matcher = HgModifyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] Error: This is an error message"); + Matcher matcher = AbstractWorkingCopyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] Error: This is an error message"); matcher.matches(); assertThat(matcher.group(1)).isEqualTo("This is an error message"); } 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..e15d54dcb6 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgTagCommandTest.java @@ -0,0 +1,72 @@ +/* + * 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.commands.PullCommand; +import com.google.inject.util.Providers; +import org.junit.Before; +import org.junit.Test; +import sonia.scm.repository.HgTestUtil; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.work.NoneCachingWorkingCopyPool; +import sonia.scm.repository.work.WorkdirProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HgTagCommandTest extends AbstractHgCommandTestBase { + + private SimpleHgWorkingCopyFactory workingCopyFactory; + + @Before + public void initWorkingCopyFactory() { + + workingCopyFactory = new SimpleHgWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider())) { + @Override + public void configure(PullCommand pullCommand) { + // we do not want to configure http hooks in this unit test + } + }; + } + + @Test + public void shouldCreateAndDeleteTagCorrectly() { + // Create + new HgTagCommand(cmdContext, workingCopyFactory).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, workingCopyFactory).delete(new TagDeleteRequest("newtag")); + tags = new HgTagsCommand(cmdContext).getTags(); + assertThat(tags).hasSize(1); + } + +} diff --git a/scm-ui/ui-components/src/buttons/Button.tsx b/scm-ui/ui-components/src/buttons/Button.tsx index e8c6494437..fc0bf7ff6b 100644 --- a/scm-ui/ui-components/src/buttons/Button.tsx +++ b/scm-ui/ui-components/src/buttons/Button.tsx @@ -94,9 +94,11 @@ class Button extends React.Component { - - {label} {children} - + {(label || children) && ( + + {label} {children} + + )} ); } diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index 32a950d4af..a261a60b60 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -75,6 +75,7 @@ export { default as SyntaxHighlighter } from "./SyntaxHighlighter"; export { default as ErrorBoundary } from "./ErrorBoundary"; export { default as OverviewPageActions } from "./OverviewPageActions"; export { default as CardColumnGroup } from "./CardColumnGroup"; +export { default as CreateTagModal } from "./modals/CreateTagModal"; export { default as CardColumn } from "./CardColumn"; export { default as CardColumnSmall } from "./CardColumnSmall"; export { default as CommaSeparatedList } from "./CommaSeparatedList"; diff --git a/scm-ui/ui-components/src/modals/CreateTagModal.tsx b/scm-ui/ui-components/src/modals/CreateTagModal.tsx new file mode 100644 index 0000000000..f94c1ab714 --- /dev/null +++ b/scm-ui/ui-components/src/modals/CreateTagModal.tsx @@ -0,0 +1,109 @@ +/* + * 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. + */ + +import React, { FC, useEffect, useState } from "react"; +import { Modal, InputField, Button, apiClient } from "@scm-manager/ui-components"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { Tag } from "@scm-manager/ui-types"; +import { isBranchValid } from "../validation"; + +type Props = WithTranslation & { + existingTagsLink: string; + tagCreationLink: string; + onClose: () => void; + onCreated: () => void; + onError: (error: Error) => void; + revision: string; +}; + +const CreateTagModal: FC = ({ t, onClose, tagCreationLink, existingTagsLink, onCreated, onError, revision }) => { + const [newTagName, setNewTagName] = useState(""); + const [loading, setLoading] = useState(false); + const [tagNames, setTagNames] = useState([]); + + useEffect(() => { + apiClient + .get(existingTagsLink) + .then(response => response.json()) + .then(json => setTagNames(json._embedded.tags.map((tag: Tag) => tag.name))); + }, [existingTagsLink]); + + const createTag = () => { + setLoading(true); + apiClient + .post(tagCreationLink, { + revision, + name: newTagName + }) + .then(onCreated) + .catch(onError) + .finally(() => setLoading(false)); + }; + + let validationError = ""; + + if (newTagName !== "") { + if (tagNames.includes(newTagName)) { + validationError = "tags.create.form.field.name.error.exists"; + } else if (!isBranchValid(newTagName)) { + validationError = "tags.create.form.field.name.error.format"; + } + } + + return ( + + setNewTagName(val)} + value={newTagName} + validationError={!!validationError} + errorMessage={t(validationError)} + /> +
{t("tags.create.hint")}
+ + } + footer={ + <> + + + + } + closeFunction={onClose} + /> + ); +}; + +export default withTranslation("repos")(CreateTagModal); diff --git a/scm-ui/ui-types/src/Changesets.ts b/scm-ui/ui-types/src/Changesets.ts index 81acd09c92..ab7fa54e61 100644 --- a/scm-ui/ui-types/src/Changesets.ts +++ b/scm-ui/ui-types/src/Changesets.ts @@ -22,10 +22,11 @@ * SOFTWARE. */ -import {Collection, Link, Links} from "./hal"; +import { Collection, Links } from "./hal"; import { Tag } from "./Tags"; import { Branch } from "./Branches"; import { Person } from "./Person"; +import { Signature } from "./Signature"; export type Changeset = Collection & { id: string; @@ -42,17 +43,6 @@ export type Changeset = Collection & { }; }; -export type Signature = { - keyId: string; - type: string; - status: "VERIFIED" | "NOT_FOUND" | "INVALID"; - owner?: string; - contacts?: Person[]; - _links?: { - rawKey?: Link; - }; -} - export type Contributor = { person: Person; type: string; diff --git a/scm-ui/ui-types/src/Signature.ts b/scm-ui/ui-types/src/Signature.ts new file mode 100644 index 0000000000..ee9cb72b16 --- /dev/null +++ b/scm-ui/ui-types/src/Signature.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +import { Person } from "./Person"; +import { Link } from "./hal"; + +export type Signature = { + keyId: string; + type: string; + status: "VERIFIED" | "NOT_FOUND" | "INVALID"; + owner?: string; + contacts?: Person[]; + _links?: { + rawKey?: Link; + }; +}; diff --git a/scm-ui/ui-types/src/Tags.ts b/scm-ui/ui-types/src/Tags.ts index 67fcf734dd..b59ac7ca1c 100644 --- a/scm-ui/ui-types/src/Tags.ts +++ b/scm-ui/ui-types/src/Tags.ts @@ -23,10 +23,12 @@ */ import { Links } from "./hal"; +import { Signature } from "./Signature"; export type Tag = { name: string; revision: string; date?: Date; + signatures: Signature[]; _links: Links; }; diff --git a/scm-ui/ui-types/src/index.ts b/scm-ui/ui-types/src/index.ts index 35adda77bb..da97204e1a 100644 --- a/scm-ui/ui-types/src/index.ts +++ b/scm-ui/ui-types/src/index.ts @@ -29,14 +29,23 @@ export { Me } from "./Me"; export { DisplayedUser, User } from "./User"; export { Group, Member } from "./Group"; -export { Repository, RepositoryCollection, RepositoryGroup, RepositoryCreation, Namespace, NamespaceCollection } from "./Repositories"; +export { + Repository, + RepositoryCollection, + RepositoryGroup, + RepositoryCreation, + Namespace, + NamespaceCollection +} from "./Repositories"; export { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes"; export { Branch, BranchRequest } from "./Branches"; export { Person } from "./Person"; -export { Changeset, Contributor, ParentChangeset, Signature } from "./Changesets"; +export { Changeset, Contributor, ParentChangeset } from "./Changesets"; + +export { Signature } from "./Signature"; export { AnnotatedSource, AnnotatedLine } from "./Annotate"; diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 3c737c893b..318e7494fd 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -103,12 +103,41 @@ }, "table": { "tags": "Tags" + }, + "create": { + "form": { + "field": { + "name": { + "label": "Name", + "error": { + "exists": "Dieser Tag existiert bereits in diesem Repository.", + "format": "Der Tag entspricht nicht dem korrekten Format." + } + } + } + }, + "title": "Neuen Tag anlegen", + "hint": "Der Tag wird automatisch mit ihrem Standardschlüssel vom SCM-Manager signiert.", + "confirm": "Tag erstellen", + "cancel": "Abbrechen" } }, "tag": { "name": "Name", "commit": "Commit", - "sources": "Sources" + "sources": "Sources", + "dangerZone": "Tag löschen", + "delete": { + "button": "Tag löschen", + "subtitle": "Tag löschen", + "description": "Gelöschte Tags können nicht wiederhergestellt werden.", + "confirmAlert": { + "title": "Tag löschen", + "message": "Möchten Sie den Tag \"{{tag}}\" wirklich löschen?", + "cancel": "Nein", + "submit": "Ja" + } + } }, "code": { "sources": "Sources", @@ -158,6 +187,9 @@ "buttons": { "details": "Details", "sources": "Sources" + }, + "tag": { + "create": "Tag erstellen" } }, "commit": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index cc4996b937..363a062904 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -86,7 +86,7 @@ "delete": { "button": "Delete branch", "subtitle": "Delete branch", - "description": "Deleted branches can not be restored.", + "description": "Deleted branches cannot be restored.", "confirmAlert": { "title": "Delete branch", "message": "Do you really want to delete the branch \"{{branch}}\"?", @@ -103,12 +103,41 @@ }, "table": { "tags": "Tags" + }, + "create": { + "form": { + "field": { + "name": { + "label": "Name", + "error": { + "exists": "This tag already exists in this repository.", + "format": "The tag name does not match the correct format." + } + } + } + }, + "title": "Create a new tag", + "hint": "The tag will be automatically signed with your default key by the SCM-Manager.", + "confirm": "Create Tag", + "cancel": "Cancel" } }, "tag": { "name": "Name", "commit": "Commit", - "sources": "Sources" + "sources": "Sources", + "dangerZone": "Delete tag", + "delete": { + "button": "Delete tag", + "subtitle": "Delete tag", + "description": "Deleted tag can not be restored.", + "confirmAlert": { + "title": "Delete tag", + "message": "Do you really want to delete the tag \"{{tag}}\"?", + "cancel": "No", + "submit": "Yes" + } + } }, "code": { "sources": "Sources", @@ -158,6 +187,9 @@ "more": "{{count}} more", "count": "{{count}} Contributor", "count_plural": "{{count}} Contributors" + }, + "tag": { + "create": "Create Tag" } }, "commit": { diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx index 32ff0d8f7a..d7d13e43e3 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx @@ -36,7 +36,7 @@ class BranchView extends React.Component { render() { const { repository, branch } = this.props; return ( -
+ <>
@@ -50,7 +50,7 @@ class BranchView extends React.Component { />
-
+ ); } } diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx index be9a70dfc8..dd5b52955f 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx @@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i import classNames from "classnames"; import styled from "styled-components"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import { Changeset, ParentChangeset, Repository } from "@scm-manager/ui-types"; +import { Changeset, Link, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types"; import { AvatarImage, AvatarWrapper, @@ -41,7 +41,10 @@ import { FileControlFactory, Icon, Level, - SignatureIcon + SignatureIcon, + Tooltip, + ErrorNotification, + CreateTagModal } from "@scm-manager/ui-components"; import ContributorTable from "./ContributorTable"; import { Link as ReactLink } from "react-router-dom"; @@ -50,10 +53,7 @@ type Props = WithTranslation & { changeset: Changeset; repository: Repository; fileControlFactory?: FileControlFactory; -}; - -type State = { - collapsed: boolean; + refetchChangeset?: () => void; }; const RightMarginP = styled.p` @@ -82,7 +82,7 @@ const ContributorLine = styled.div` `; const ContributorColumn = styled.p` - flex-grow: 1; + flex-grow: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -108,7 +108,6 @@ const ContributorToggleLine = styled.p` const ChangesetSummary = styled.div` display: flex; - justify-content: space-between; `; const SeparatedParents = styled.div` @@ -147,7 +146,7 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => { {signatureIcon} - + ( {t("changeset.contributors.count", { count: countContributors(changeset) })} @@ -159,109 +158,131 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => { ); }; -class ChangesetDetails extends React.Component { - constructor(props: Props) { - super(props); - this.state = { - collapsed: false - }; - } +const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory, t, refetchChangeset }) => { + const [collapsed, setCollapsed] = useState(false); + const [isTagCreationModalVisible, setTagCreationModalVisible] = useState(false); + const [error, setError] = useState(); - render() { - const { changeset, repository, fileControlFactory, t } = this.props; - const { collapsed } = this.state; + const description = changesets.parseDescription(changeset.description); + const id = ; + const date = ; + const parents = changeset._embedded.parents.map((parent: ParentChangeset, index: number) => ( + + {parent.id.substring(0, 7)} + + )); + const showCreateButton = "tag" in changeset._links; - const description = changesets.parseDescription(changeset.description); - const id = ; - const date = ; - const parents = changeset._embedded.parents.map((parent: ParentChangeset, index: number) => ( - - {parent.id.substring(0, 7)} - - )); - - return ( - <> -
-

- - - -

-
- - - - - -
- - -

- -

- {parents?.length > 0 && ( - - {t("changeset.parents.label", { count: parents?.length }) + ": "} - {parents} - - )} -
-
-
- -
-
-

- {description.message.split("\n").map((item, key) => { - return ( - - - - -
-
- ); - })} -

-
-
- - } - /> - -
- - ); - } - - collapseDiffs = () => { - this.setState(state => ({ - collapsed: !state.collapsed - })); + const collapseDiffs = () => { + setCollapsed(!collapsed); }; -} + + if (error) { + return ; + } + + return ( + <> +
+

+ + + +

+
+ + + + + +
+ + +

+ +

+ {parents?.length > 0 && ( + + {t("changeset.parents.label", { count: parents?.length }) + ": "} + {parents} + + )} +
+
+
+ +
+ + {showCreateButton && ( +
+ +
+ )} + {isTagCreationModalVisible && ( + setTagCreationModalVisible(false)} + onCreated={() => { + refetchChangeset?.(); + setTagCreationModalVisible(false); + }} + onError={setError} + tagCreationLink={(changeset._links["tag"] as Link).href} + existingTagsLink={(repository._links["tags"] as Link).href} + /> + )} +
+

+ {description.message.split("\n").map((item, key) => { + return ( + + + + +
+
+ ); + })} +

+
+
+ + } + /> + +
+ + ); +}; export default withTranslation("repos")(ChangesetDetails); diff --git a/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx b/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx index fd9c55d32b..20791822aa 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx @@ -29,6 +29,7 @@ import { WithTranslation, withTranslation } from "react-i18next"; import { Changeset, Repository } from "@scm-manager/ui-types"; import { ErrorPage, Loading } from "@scm-manager/ui-components"; import { + fetchChangeset, fetchChangesetIfNeeded, getChangeset, getFetchChangesetFailure, @@ -45,6 +46,7 @@ type Props = WithTranslation & { loading: boolean; error: Error; fetchChangesetIfNeeded: (repository: Repository, id: string) => void; + refetchChangeset: (repository: Repository, id: string) => void; match: any; }; @@ -62,7 +64,7 @@ class ChangesetView extends React.Component { } render() { - const { changeset, loading, error, t, repository, fileControlFactoryFactory } = this.props; + const { changeset, loading, error, t, repository, fileControlFactoryFactory, refetchChangeset } = this.props; if (error) { return ; @@ -75,6 +77,7 @@ class ChangesetView extends React.Component { changeset={changeset} repository={repository} fileControlFactory={fileControlFactoryFactory && fileControlFactoryFactory(changeset)} + refetchChangeset={() => refetchChangeset(repository, changeset.id)} /> ); } @@ -98,6 +101,9 @@ const mapDispatchToProps = (dispatch: any) => { return { fetchChangesetIfNeeded: (repository: Repository, id: string) => { dispatch(fetchChangesetIfNeeded(repository, id)); + }, + refetchChangeset: (repository: Repository, id: string) => { + dispatch(fetchChangeset(repository, id)); } }; }; diff --git a/scm-ui/ui-webapp/src/repos/tags/components/TagDetail.tsx b/scm-ui/ui-webapp/src/repos/tags/components/TagDetail.tsx index 0bbcdb56dc..5db1fe962f 100644 --- a/scm-ui/ui-webapp/src/repos/tags/components/TagDetail.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/components/TagDetail.tsx @@ -25,7 +25,7 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import { Repository, Tag } from "@scm-manager/ui-types"; -import { DateFromNow } from "@scm-manager/ui-components"; +import { DateFromNow, SignatureIcon } from "@scm-manager/ui-components"; import styled from "styled-components"; import TagButtonGroup from "./TagButtonGroup"; @@ -58,9 +58,10 @@ const TagDetail: FC = ({ tag, repository }) => { return (
- - {tag.name} - + + {tag.name} + + {t("tags.overview.created")} diff --git a/scm-ui/ui-webapp/src/repos/tags/components/TagRow.tsx b/scm-ui/ui-webapp/src/repos/tags/components/TagRow.tsx index 00b18da623..f1b601be81 100644 --- a/scm-ui/ui-webapp/src/repos/tags/components/TagRow.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/components/TagRow.tsx @@ -24,14 +24,16 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; -import { Tag } from "@scm-manager/ui-types"; +import { Link as RouterLink } from "react-router-dom"; +import { Tag, Link } from "@scm-manager/ui-types"; import styled from "styled-components"; -import { DateFromNow } from "@scm-manager/ui-components"; +import { DateFromNow, Icon } from "@scm-manager/ui-components"; type Props = { tag: Tag; baseUrl: string; + onDelete: (tag: Tag) => void; + // deleting: boolean; }; const Created = styled.span` @@ -39,20 +41,32 @@ const Created = styled.span` font-size: 0.8rem; `; -const TagRow: FC = ({ tag, baseUrl }) => { +const TagRow: FC = ({ tag, baseUrl, onDelete }) => { const [t] = useTranslation("repos"); + let deleteButton; + if ((tag?._links?.delete as Link)?.href) { + deleteButton = ( + onDelete(tag)}> + + + + + ); + } + const to = `${baseUrl}/${encodeURIComponent(tag.name)}/info`; return ( - + {tag.name} {t("tags.overview.created")} - + + {deleteButton} ); }; diff --git a/scm-ui/ui-webapp/src/repos/tags/components/TagTable.tsx b/scm-ui/ui-webapp/src/repos/tags/components/TagTable.tsx index 4cd694766f..c15753e9b8 100644 --- a/scm-ui/ui-webapp/src/repos/tags/components/TagTable.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/components/TagTable.tsx @@ -22,38 +22,83 @@ * SOFTWARE. */ -import React, { FC } from "react"; -import { Tag } from "@scm-manager/ui-types"; +import React, { FC, useState } from "react"; +import { Link, Tag } from "@scm-manager/ui-types"; import { useTranslation } from "react-i18next"; import TagRow from "./TagRow"; +import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components"; type Props = { baseUrl: string; tags: Tag[]; + fetchTags: () => void; }; -const TagTable: FC = ({ baseUrl, tags }) => { +const TagTable: FC = ({ baseUrl, tags, fetchTags }) => { const [t] = useTranslation("repos"); + const [showConfirmAlert, setShowConfirmAlert] = useState(false); + const [error, setError] = useState(); + const [tagToBeDeleted, setTagToBeDeleted] = useState(); + + const onDelete = (tag: Tag) => { + setTagToBeDeleted(tag); + setShowConfirmAlert(true); + }; + + const abortDelete = () => { + setTagToBeDeleted(undefined); + setShowConfirmAlert(false); + }; + + const deleteTag = () => { + apiClient + .delete((tagToBeDeleted?._links.delete as Link).href) + .then(() => fetchTags()) + .catch(setError); + }; const renderRow = () => { let rowContent = null; if (tags) { rowContent = tags.map((tag, index) => { - return ; + return ; }); } return rowContent; }; + const confirmAlert = ( + deleteTag() + }, + { + label: t("tag.delete.confirmAlert.cancel"), + onClick: () => abortDelete() + } + ]} + close={() => abortDelete()} + /> + ); + return ( - - - - - - - {renderRow()} -
{t("tags.table.tags")}
+ <> + {showConfirmAlert && confirmAlert} + {error && } + + + + + + + {renderRow()} +
{t("tags.table.tags")}
+ ); }; diff --git a/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx b/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx index a9df4b289a..6c94fb72e7 100644 --- a/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx @@ -26,6 +26,7 @@ import React, { FC } from "react"; import { Repository, Tag } from "@scm-manager/ui-types"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import TagDetail from "./TagDetail"; +import TagDangerZone from "../container/TagDangerZone"; type Props = { repository: Repository; @@ -47,6 +48,7 @@ const TagView: FC = ({ repository, tag }) => { }} />
+ ); }; diff --git a/scm-ui/ui-webapp/src/repos/tags/container/DeleteTag.tsx b/scm-ui/ui-webapp/src/repos/tags/container/DeleteTag.tsx new file mode 100644 index 0000000000..83cd2d0db3 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/tags/container/DeleteTag.tsx @@ -0,0 +1,93 @@ +/* + * 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. + */ + +import React, { FC, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import { apiClient, ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; +import { Link, Repository, Tag } from "@scm-manager/ui-types"; + +type Props = { + repository: Repository; + tag: Tag; +}; + +const DeleteTag: FC = ({ tag, repository }) => { + const [showConfirmAlert, setShowConfirmAlert] = useState(false); + const [error, setError] = useState(); + const [t] = useTranslation("repos"); + const history = useHistory(); + + const deleteBranch = () => { + apiClient + .delete((tag._links.delete as Link).href) + .then(() => history.push(`/repo/${repository.namespace}/${repository.name}/tags/`)) + .catch(setError); + }; + + if (!tag._links.delete) { + return null; + } + + let confirmAlert = null; + if (showConfirmAlert) { + confirmAlert = ( + deleteBranch() + }, + { + label: t("tag.delete.confirmAlert.cancel"), + onClick: () => null + } + ]} + close={() => setShowConfirmAlert(false)} + /> + ); + } + + return ( + <> + + {showConfirmAlert && confirmAlert} + + {t("tag.delete.subtitle")} +
+ {t("tag.delete.description")} +

+ } + right={ setShowConfirmAlert(true)} />} + /> + + ); +}; + +export default DeleteTag; diff --git a/scm-ui/ui-webapp/src/repos/tags/container/TagDangerZone.tsx b/scm-ui/ui-webapp/src/repos/tags/container/TagDangerZone.tsx new file mode 100644 index 0000000000..6e3b421939 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/tags/container/TagDangerZone.tsx @@ -0,0 +1,59 @@ +/* + * 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. + */ + +import React, { FC } from "react"; +import { Repository, Tag } from "@scm-manager/ui-types"; +import { Subtitle } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import { DangerZoneContainer } from "../../containers/RepositoryDangerZone"; +import DeleteTag from "./DeleteTag"; + +type Props = { + repository: Repository; + tag: Tag; +}; + +const TagDangerZone: FC = ({ repository, tag }) => { + const [t] = useTranslation("repos"); + + const dangerZone = []; + + if (tag?._links?.delete) { + dangerZone.push(); + } + + if (dangerZone.length === 0) { + return null; + } + + return ( + <> +
+ + {dangerZone} + + ); +}; + +export default TagDangerZone; diff --git a/scm-ui/ui-webapp/src/repos/tags/container/TagsOverview.tsx b/scm-ui/ui-webapp/src/repos/tags/container/TagsOverview.tsx index 42de372b60..13a5c1e2ff 100644 --- a/scm-ui/ui-webapp/src/repos/tags/container/TagsOverview.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/container/TagsOverview.tsx @@ -40,7 +40,7 @@ const TagsOverview: FC = ({ repository, baseUrl }) => { const [error, setError] = useState(undefined); const [tags, setTags] = useState([]); - useEffect(() => { + const fetchTags = () => { const link = (repository._links?.tags as Link)?.href; if (link) { setLoading(true); @@ -51,12 +51,16 @@ const TagsOverview: FC = ({ repository, baseUrl }) => { .then(() => setLoading(false)) .catch(setError); } + }; + + useEffect(() => { + fetchTags(); }, [repository]); const renderTagsTable = () => { if (!loading && tags?.length > 0) { orderTags(tags); - return ; + return ; } return {t("tags.overview.noTags")}; }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java index 258dd1e3e5..8ef295c4be 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -354,7 +354,7 @@ public class BranchRootResource { @PathParam("name") String name, @PathParam("branch") String branch) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - RepositoryPermissions.modify(repositoryService.getRepository()).check(); + RepositoryPermissions.push(repositoryService.getRepository()).check(); Optional branchToBeDeleted = repositoryService.getBranchesCommand().getBranches().getBranches().stream() .filter(b -> b.getName().equalsIgnoreCase(branch)) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java index 0dc9e4310a..fc887ff62d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java @@ -34,6 +34,7 @@ import sonia.scm.repository.Changeset; import sonia.scm.repository.Contributor; import sonia.scm.repository.Person; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.Signature; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; @@ -116,6 +117,9 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa if (repositoryService.isSupported(Command.TAGS)) { embeddedBuilder.with("tags", tagCollectionToDtoMapper.getMinimalEmbeddedTagDtoList(namespace, name, source.getTags())); } + if (repositoryService.isSupported(Command.TAG) && RepositoryPermissions.push(repository).isPermitted()) { + linksBuilder.single(link("tag", resourceLinks.tag().create(namespace, name))); + } if (repositoryService.isSupported(Command.BRANCHES)) { embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(repository, getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId())))); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 7bd6c353f5..79cfc3ace0 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -445,6 +445,14 @@ class ResourceLinks { return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("get").parameters(tagName).href(); } + String delete(String namespace, String name, String tagName) { + return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("delete").parameters(tagName).href(); + } + + String create(String namespace, String name) { + return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("create").parameters().href(); + } + String all(String namespace, String name) { return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getAll").parameters().href(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java index fb1ca05d5a..eed2446533 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java @@ -28,7 +28,7 @@ import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; -import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; import sonia.scm.repository.Tag; import java.util.Collection; @@ -40,7 +40,6 @@ import static java.util.stream.Collectors.toList; public class TagCollectionToDtoMapper { - private final ResourceLinks resourceLinks; private final TagToTagDtoMapper tagToTagDtoMapper; @@ -50,12 +49,12 @@ public class TagCollectionToDtoMapper { this.tagToTagDtoMapper = tagToTagDtoMapper; } - public HalRepresentation map(String namespace, String name, Collection tags) { - return new HalRepresentation(createLinks(namespace, name), embedDtos(getTagDtoList(namespace, name, tags))); + public HalRepresentation map(Collection tags, Repository repository) { + return new HalRepresentation(createLinks(repository.getNamespace(), repository.getName()), embedDtos(getTagDtoList(tags, repository))); } - public List getTagDtoList(String namespace, String name, Collection tags) { - return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, new NamespaceAndName(namespace, name))).collect(toList()); + public List getTagDtoList(Collection tags, Repository repository) { + return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, repository)).collect(toList()); } public List getMinimalEmbeddedTagDtoList(String namespace, String name, Collection tags) { 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 bd26bb8807..17d7fac9b2 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) { super(links); } 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..03a6e26dfa --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRequestDto.java @@ -0,0 +1,49 @@ +/* + * 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; +import javax.validation.constraints.Pattern; + +import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES; +import static sonia.scm.repository.Tag.VALID_REV; + +@Getter +@Setter +public class TagRequestDto { + @Pattern(regexp = VALID_REV) + @NotEmpty + @Length(min = 1, max = 100) + private String revision; + + @Pattern(regexp = VALID_BRANCH_NAMES) + @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..0f8e40c639 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,12 +21,15 @@ * 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.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import sonia.scm.NotFoundException; import sonia.scm.repository.NamespaceAndName; @@ -36,16 +39,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 +63,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 @@ -89,7 +101,7 @@ public class TagRootResource { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { Tags tags = getTags(repositoryService); if (tags != null && tags.getTags() != null) { - return Response.ok(tagCollectionToDtoMapper.map(namespace, name, tags.getTags())).build(); + return Response.ok(tagCollectionToDtoMapper.map(tags.getTags(), repositoryService.getRepository())).build(); } else { return Response.status(Response.Status.INTERNAL_SERVER_ERROR) .entity("Error on getting tag from repository.") @@ -98,6 +110,66 @@ public class TagRootResource { } } + @POST + @Path("") + @Produces(VndMediaType.TAG_REQUEST) + @Operation(summary = "Create tag", + description = "Creates a new tag.", + tags = "Repository", + requestBody = @RequestBody( + content = @Content( + mediaType = VndMediaType.TAG_REQUEST, + schema = @Schema(implementation = TagRequestDto.class), + examples = @ExampleObject( + name = "Create a new tag for a revision", + value = "{\n \"revision\":\"734713bc047d87bf7eac9674765ae793478c50d3\",\n \"name\":\"v1.1.0\"\n}", + summary = "Create a tag" + ) + ) + )) + @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(repositoryService.getRepository())); + } + 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}") @@ -136,7 +208,7 @@ public class TagRootResource { .filter(t -> tagName.equals(t.getName())) .findFirst() .orElseThrow(() -> createNotFoundException(namespace, name, tagName)); - return Response.ok(tagToTagDtoMapper.map(tag, namespaceAndName)).build(); + return Response.ok(tagToTagDtoMapper.map(tag, repositoryService.getRepository())).build(); } else { return Response.status(Response.Status.INTERNAL_SERVER_ERROR) .entity("Error on getting tag from repository.") @@ -145,6 +217,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) throws IOException { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + RepositoryPermissions.push(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 +266,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 4788ee9595..290ad757aa 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 @@ -31,12 +31,12 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; import org.mapstruct.ObjectFactory; -import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.Tag; import sonia.scm.web.EdisonHalAppender; import javax.inject.Inject; - import java.time.Instant; import java.util.Optional; @@ -52,17 +52,23 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper { @Mapping(target = "date", source = "date", qualifiedByName = "mapDate") @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes - public abstract TagDto map(Tag tag, @Context NamespaceAndName namespaceAndName); + @Mapping(target = "signatures") + public abstract TagDto map(Tag tag, @Context Repository repository); @ObjectFactory - TagDto createDto(@Context NamespaceAndName namespaceAndName, Tag tag) { + TagDto createDto(@Context Repository repository, Tag tag) { Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.tag().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getName())) - .single(link("sources", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision()))) - .single(link("changeset", resourceLinks.changeset().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision()))); + .self(resourceLinks.tag().self(repository.getNamespace(), repository.getName(), tag.getName())) + .single(link("sources", resourceLinks.source().self(repository.getNamespace(), repository.getName(), tag.getRevision()))) + .single(link("changeset", resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), tag.getRevision()))); + + if (tag.getDeletable() && RepositoryPermissions.push(repository).isPermitted()) { + linksBuilder + .single(link("delete", resourceLinks.tag().delete(repository.getNamespace(), repository.getName(), tag.getName()))); + } Embedded.Builder embeddedBuilder = embeddedBuilder(); - applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), tag, namespaceAndName); + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), tag, repository); return new TagDto(linksBuilder.build(), embeddedBuilder.build()); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java index 726cbc89e4..7655dd7c9b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java @@ -24,8 +24,8 @@ package sonia.scm.api.v2.resources; -import com.google.inject.util.Providers; import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.support.SubjectThreadState; import org.apache.shiro.util.ThreadContext; @@ -46,17 +46,24 @@ 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.repository.api.TagsCommandBuilder; import sonia.scm.web.RestDispatcher; import sonia.scm.web.VndMediaType; +import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @Slf4j @@ -79,6 +86,12 @@ public class TagRootResourceTest extends RepositoryTestBase { @Mock private TagsCommandBuilder tagsCommandBuilder; + @Mock + private TagCommandBuilder tagCommandBuilder; + @Mock + private TagCommandBuilder.TagCreateCommandBuilder tagCreateCommandBuilder; + @Mock + private TagCommandBuilder.TagDeleteCommandBuilder tagDeleteCommandBuilder; private TagCollectionToDtoMapper tagCollectionToDtoMapper; @InjectMocks @@ -89,17 +102,21 @@ public class TagRootResourceTest extends RepositoryTestBase { @Before - public void prepareEnvironment() throws Exception { + public void prepareEnvironment() { tagCollectionToDtoMapper = new TagCollectionToDtoMapper(resourceLinks, tagToTagDtoMapper); - tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper); + tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper, resourceLinks); dispatcher.addSingletonResource(getRepositoryRootResource()); when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService); when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo")); when(repositoryService.getTagsCommand()).thenReturn(tagsCommandBuilder); + when(repositoryService.getTagCommand()).thenReturn(tagCommandBuilder); subjectThreadState.bind(); ThreadContext.bind(subject); when(subject.isPermitted(any(String.class))).thenReturn(true); + when(tagCreateCommandBuilder.setName(any())).thenReturn(tagCreateCommandBuilder); + when(tagCreateCommandBuilder.setRevision(any())).thenReturn(tagCreateCommandBuilder); + when(tagDeleteCommandBuilder.setName(any())).thenReturn(tagDeleteCommandBuilder); } @After @@ -211,4 +228,116 @@ public class TagRootResourceTest extends RepositoryTestBase { assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", tag2))); assertTrue(response.getContentAsString().contains(String.format("\"revision\":\"%s\"", revision2))); } + + @Test + public void shouldCreateTag() throws URISyntaxException, IOException { + Tags tags = new Tags(); + tags.setTags(Lists.emptyList()); + when(tagsCommandBuilder.getTags()).thenReturn(tags); + when(tagCommandBuilder.create()).thenReturn(tagCreateCommandBuilder); + when(tagCreateCommandBuilder.execute()).thenReturn(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411")); + MockHttpRequest request = MockHttpRequest + .post(TAG_URL) + .content("{\"name\": \"newtag\",\"revision\":\"592d797cd36432e591416e8b2b98154f4f163411\"}".getBytes()) + .contentType(VndMediaType.TAG_REQUEST); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(201, response.getStatus()); + assertEquals( + URI.create("/v2/repositories/space/repo/tags/newtag"), + response.getOutputHeaders().getFirst("Location")); + } + + @Test + public void shouldNotCreateTagIfNotPermitted() throws IOException, URISyntaxException { + doThrow(AuthorizationException.class).when(subject).checkPermission("repository:push:repoId"); + Tags tags = new Tags(); + tags.setTags(Lists.emptyList()); + when(tagsCommandBuilder.getTags()).thenReturn(tags); + when(tagCommandBuilder.create()).thenReturn(tagCreateCommandBuilder); + when(tagCreateCommandBuilder.execute()).thenReturn(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411")); + MockHttpRequest request = MockHttpRequest + .post(TAG_URL) + .content("{\"name\": \"newtag\",\"revision\":\"592d797cd36432e591416e8b2b98154f4f163411\"}".getBytes()) + .contentType(VndMediaType.TAG_REQUEST); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(403, response.getStatus()); + verify(tagCommandBuilder, never()).create(); + } + + @Test + public void shouldThrowExceptionIfTagAlreadyExists() throws URISyntaxException, IOException { + Tags tags = new Tags(); + tags.setTags(Collections.singletonList(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411"))); + when(tagsCommandBuilder.getTags()).thenReturn(tags); + when(tagCommandBuilder.create()).thenReturn(tagCreateCommandBuilder); + when(tagCreateCommandBuilder.execute()).thenReturn(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411")); + MockHttpRequest request = MockHttpRequest + .post(TAG_URL) + .content("{\"name\": \"newtag\",\"revision\":\"592d797cd36432e591416e8b2b98154f4f163411\"}".getBytes()) + .contentType(VndMediaType.TAG_REQUEST); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(409, response.getStatus()); + verify(tagCommandBuilder, never()).create(); + } + + @Test + public void shouldDeleteTag() throws IOException, URISyntaxException { + Tags tags = new Tags(); + tags.setTags(Collections.singletonList(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411"))); + when(tagsCommandBuilder.getTags()).thenReturn(tags); + when(tagCommandBuilder.delete()).thenReturn(tagDeleteCommandBuilder); + + MockHttpRequest request = MockHttpRequest + .delete(TAG_URL + "newtag"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(204, response.getStatus()); + verify(tagCommandBuilder).delete(); + } + + @Test + public void shouldReturn204EvenIfTagDoesntExist() throws IOException, URISyntaxException { + Tags tags = new Tags(); + tags.setTags(Collections.emptyList()); + when(tagsCommandBuilder.getTags()).thenReturn(tags); + when(tagCommandBuilder.delete()).thenReturn(tagDeleteCommandBuilder); + + MockHttpRequest request = MockHttpRequest + .delete(TAG_URL + "newtag"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(204, response.getStatus()); + verify(tagCommandBuilder, never()).delete(); + } + + @Test + public void shouldNotDeleteTagIfNotPermitted() throws IOException, URISyntaxException { + doThrow(AuthorizationException.class).when(subject).checkPermission("repository:modify:repoId"); + Tags tags = new Tags(); + tags.setTags(Collections.singletonList(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411"))); + when(tagsCommandBuilder.getTags()).thenReturn(tags); + when(tagCommandBuilder.delete()).thenReturn(tagDeleteCommandBuilder); + + MockHttpRequest request = MockHttpRequest + .delete(TAG_URL + "newtag"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(403, response.getStatus()); + verify(tagCommandBuilder, never()).delete(); + } } 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..3fea41860b 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 @@ -24,17 +24,29 @@ package sonia.scm.api.v2.resources; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryTestData; +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; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class TagToTagDtoMapperTest { @@ -45,25 +57,76 @@ class TagToTagDtoMapperTest { @InjectMocks private TagToTagDtoMapperImpl mapper; + @Mock + private Subject subject; + + @BeforeEach + void setupSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void tearDown() { + ThreadContext.unbindSubject(); + } + @Test void shouldAppendLinks() { HalEnricherRegistry registry = new HalEnricherRegistry(); registry.register(Tag.class, (ctx, appender) -> { - NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class); + Repository repository = ctx.oneRequireByType(Repository.class); Tag tag = ctx.oneRequireByType(Tag.class); - appender.appendLink("yo", "http://" + repository.logString() + "/" + tag.getName()); + + appender.appendLink("yo", "http://" + repository.getNamespace() + "/" + repository.getName() + "/" + tag.getName()); }); mapper.setRegistry(registry); - TagDto dto = mapper.map(new Tag("1.0.0", "42"), new NamespaceAndName("hitchhiker", "hog")); - assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/hog/1.0.0"); + TagDto dto = mapper.map(new Tag("1.0.0", "42"), RepositoryTestData.createHeartOfGold()); + assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/HeartOfGold/1.0.0"); } @Test void shouldMapDate() { final long now = Instant.now().getEpochSecond() * 1000; - TagDto dto = mapper.map(new Tag("1.0.0", "42", now), new NamespaceAndName("hitchhiker", "hog")); + TagDto dto = mapper.map(new Tag("1.0.0", "42", now), RepositoryTestData.createHeartOfGold()); assertThat(dto.getDate()).isEqualTo(Instant.ofEpochMilli(now)); } + @Test + void shouldContainSignatureArray() { + TagDto dto = mapper.map(new Tag("1.0.0", "42"), RepositoryTestData.createHeartOfGold()); + 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, RepositoryTestData.createHeartOfGold()); + assertThat(dto.getSignatures()).isNotEmpty(); + } + + @Test + void shouldAddDeleteLink() { + Repository repository = RepositoryTestData.createHeartOfGold(); + when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(true); + final Tag tag = new Tag("1.0.0", "42"); + TagDto dto = mapper.map(tag, repository); + assertThat(dto.getLinks().getLinkBy("delete")).isNotEmpty(); + } + + @Test + void shouldNotAddDeleteLinkIfPermissionsAreMissing() { + final Tag tag = new Tag("1.0.0", "42"); + TagDto dto = mapper.map(tag, RepositoryTestData.createHeartOfGold()); + assertThat(dto.getLinks().getLinkBy("delete")).isEmpty(); + } + + @Test + void shouldNotAddDeleteLinksForUndeletableTags() { + final Tag tag = new Tag("1.0.0", "42", null, false); + TagDto dto = mapper.map(tag, RepositoryTestData.createHeartOfGold()); + assertThat(dto.getLinks().getLinkBy("delete")).isEmpty(); + } + }