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

+#### Tags
+
+Alle Tags eines Changesets werden in der oberen rechten Ecke der Detailseite angezeigt.
+
+
+
+#### 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.
+
+
+
### 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-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.
+
+
+
+### 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.
-

+#### Tags
+
+All tags for a changeset are displayed in the top-right corner of the details page.
+
+
+
+#### 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.
+
+
+
### 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 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.
+
+
+
+### 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
+ }
+ 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();
+ }
+
}