Merge branch 'develop' into feature/import_git_from_url

This commit is contained in:
Eduard Heimbuch
2020-12-02 14:39:45 +01:00
71 changed files with 2240 additions and 588 deletions

View File

@@ -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)) - 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)) - 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)) - 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))
- Repository import via URL for git ([#1460](https://github.com/scm-manager/scm-manager/pull/1460)) - Repository import via URL for git ([#1460](https://github.com/scm-manager/scm-manager/pull/1460))
### Changed ### Changed

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View File

@@ -34,6 +34,19 @@ Beispielsweise wird der Text hitchhiker/HeartOfGold@1a2b3c4 zu einem Link zu dem
![Repository-Code-Changesets](assets/repository-code-changesetDetails.png) ![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 ### 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: 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:

View File

@@ -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. Hier wird ein Befehl zum Arbeiten mit dem Tag auf einer Kommandozeile aufgeführt.
![Tag Detailseite](assets/repository-tag-detailView.png) ![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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 211 KiB

View File

@@ -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. 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. 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) ![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 ### 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: 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:

View File

@@ -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. This page shows a command to work with the tag on the command line.
![Tag Details Page](assets/repository-tag-detailView.png) ![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.

View File

@@ -28,7 +28,10 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Pattern;
/** /**
* Represents a tag in a repository. * Represents a tag in a repository.
@@ -41,9 +44,14 @@ import java.util.Optional;
@Getter @Getter
public final class Tag { 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 name;
private final String revision; private final String revision;
private final Long date; private final Long date;
private final List<Signature> signatures = new ArrayList<>();
private final Boolean deletable;
/** /**
* Constructs a new tag. * Constructs a new tag.
@@ -65,9 +73,24 @@ public final class Tag {
* @since 2.5.0 * @since 2.5.0
*/ */
public Tag(String name, String revision, Long date) { 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.name = name;
this.revision = revision; this.revision = revision;
this.date = date; this.date = date;
this.deletable = deletable;
} }
/** /**
@@ -89,4 +112,8 @@ public final class Tag {
public Optional<Long> getDate() { public Optional<Long> getDate() {
return Optional.ofNullable(date); return Optional.ofNullable(date);
} }
public void addSignature(Signature signature) {
this.signatures.add(signature);
}
} }

View File

@@ -62,5 +62,10 @@ public enum Command
/** /**
* @since 2.10.0 * @since 2.10.0
*/ */
LOOKUP; LOOKUP,
/**
* @since 2.11.0
*/
TAG;
} }

View File

@@ -377,6 +377,18 @@ public final class RepositoryService implements Closeable {
repository); 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. * The unbundle command restores a repository from the given bundle.
* *

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -246,6 +246,15 @@ public abstract class RepositoryServiceProvider implements Closeable
throw new CommandNotSupportedException(Command.TAGS); throw new CommandNotSupportedException(Command.TAGS);
} }
/**
* @since 2.11.0
*/
public TagCommand getTagCommand()
{
throw new CommandNotSupportedException(Command.TAG);
}
/** /**
* Method description * Method description
* *

View File

@@ -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;
}

View File

@@ -51,6 +51,7 @@ public class VndMediaType {
public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX; public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX;
public static final String TAG = PREFIX + "tag" + SUFFIX; public static final String TAG = PREFIX + "tag" + SUFFIX;
public static final String TAG_COLLECTION = PREFIX + "tagCollection" + 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 = PREFIX + "branch" + SUFFIX;
public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX; public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX;
public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX; public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX;

View File

@@ -175,12 +175,12 @@ public class RepositoryAccessITCase {
.as("assert tag size") .as("assert tag size")
.isNotNull() .isNotNull()
.size() .size()
.isGreaterThan(0); .isPositive();
assertThat(response.body().jsonPath().getMap("_embedded.tags.find{it.name=='" + tagName + "'}")) assertThat(response.body().jsonPath().getMap("_embedded.tags.find{it.name=='" + tagName + "'}"))
.as("assert tag has attributes for name, revision, date and links") .as("assert tag has attributes for name, revision, date and links")
.isNotNull() .isNotNull()
.hasSize(4) .hasSize(5)
.containsEntry("name", tagName) .containsEntry("name", tagName)
.containsEntry("revision", changeset.getId()); .containsEntry("revision", changeset.getId());

View File

@@ -55,6 +55,8 @@ import org.eclipse.jgit.util.LfsFactory;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry; import sonia.scm.ContextEntry;
import sonia.scm.security.GPG;
import sonia.scm.security.PublicKey;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
import sonia.scm.web.GitUserAgentProvider; import sonia.scm.web.GitUserAgentProvider;
@@ -63,6 +65,7 @@ import javax.servlet.http.HttpServletRequest;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Collections;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -73,73 +76,37 @@ import static java.util.Optional.of;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
/** /**
*
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
public final class GitUtil public final class GitUtil {
{
private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider(); private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider();
/** Field description */
public static final String REF_HEAD = "HEAD"; public static final String REF_HEAD = "HEAD";
/** Field description */
public static final String REF_HEAD_PREFIX = "refs/heads/"; public static final String REF_HEAD_PREFIX = "refs/heads/";
/** Field description */
public static final String REF_MASTER = "master"; public static final String REF_MASTER = "master";
/** Field description */
private static final String DIRECTORY_DOTGIT = ".git"; private static final String DIRECTORY_DOTGIT = ".git";
/** Field description */
private static final String DIRECTORY_OBJETCS = "objects"; private static final String DIRECTORY_OBJETCS = "objects";
/** Field description */
private static final String DIRECTORY_REFS = "refs"; private static final String DIRECTORY_REFS = "refs";
/** Field description */
private static final String PREFIX_HEADS = "refs/heads/"; private static final String PREFIX_HEADS = "refs/heads/";
/** Field description */
private static final String PREFIX_TAG = "refs/tags/"; private static final String PREFIX_TAG = "refs/tags/";
/** Field description */
private static final String REFSPEC = "+refs/heads/*:refs/remote/scm/%s/*"; private static final String REFSPEC = "+refs/heads/*:refs/remote/scm/%s/*";
/** Field description */
private static final String REMOTE_REF = "refs/remote/scm/%s/%s"; private static final String REMOTE_REF = "refs/remote/scm/%s/%s";
/** Field description */
private static final int TIMEOUT = 5; 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); private static final Logger logger = LoggerFactory.getLogger(GitUtil.class);
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
/** private GitUtil() {
* Constructs ... }
*
*/
private GitUtil() {}
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
/** public static void close(org.eclipse.jgit.lib.Repository repo) {
* Method description if (repo != null) {
*
*
* @param repo
*/
public static void close(org.eclipse.jgit.lib.Repository repo)
{
if (repo != null)
{
repo.close(); repo.close();
} }
} }
@@ -147,42 +114,30 @@ public final class GitUtil
/** /**
* TODO cache * TODO cache
* *
*
* @param repository * @param repository
* @param revWalk * @param revWalk
*
*
* @return * @return
*/ */
public static Multimap<ObjectId, public static Multimap<ObjectId,
String> createTagMap(org.eclipse.jgit.lib.Repository repository, String> createTagMap(org.eclipse.jgit.lib.Repository repository,
RevWalk revWalk) RevWalk revWalk) {
{
Multimap<ObjectId, String> tags = ArrayListMultimap.create(); Multimap<ObjectId, String> tags = ArrayListMultimap.create();
Map<String, Ref> tagMap = repository.getTags(); Map<String, Ref> tagMap = repository.getTags();
if (tagMap != null) if (tagMap != null) {
{ for (Map.Entry<String, Ref> e : tagMap.entrySet()) {
for (Map.Entry<String, Ref> e : tagMap.entrySet()) try {
{
try
{
RevCommit c = getCommit(repository, revWalk, e.getValue()); RevCommit c = getCommit(repository, revWalk, e.getValue());
if (c != null) if (c != null) {
{
tags.put(c.getId(), e.getKey()); tags.put(c.getId(), e.getKey());
} } else {
else if (logger.isWarnEnabled())
{
logger.warn("could not find commit for tag {}", e.getKey()); 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); 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) { public static FetchResult fetch(Git git, File directory, Repository remoteRepository) {
try try {
{
FetchCommand fetch = git.fetch(); FetchCommand fetch = git.fetch();
fetch.setRemote(directory.getAbsolutePath()); fetch.setRemote(directory.getAbsolutePath());
@@ -202,123 +156,63 @@ public final class GitUtil
fetch.setTimeout((int) TimeUnit.MINUTES.toSeconds(TIMEOUT)); fetch.setTimeout((int) TimeUnit.MINUTES.toSeconds(TIMEOUT));
return fetch.call(); return fetch.call();
} } catch (GitAPIException ex) {
catch (GitAPIException ex)
{
throw new InternalRepositoryException(ContextEntry.ContextBuilder.entity("Remote", directory.toString()).in(remoteRepository), "could not fetch", 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) public static org.eclipse.jgit.lib.Repository open(File directory)
throws IOException throws IOException {
{
FS fs = FS.DETECTED; FS fs = FS.DETECTED;
FileRepositoryBuilder builder = new FileRepositoryBuilder(); FileRepositoryBuilder builder = new FileRepositoryBuilder();
builder.setFS(fs); builder.setFS(fs);
if (isGitDirectory(fs, directory)) if (isGitDirectory(fs, directory)) {
{
// bare repository // bare repository
builder.setGitDir(directory).setBare(); builder.setGitDir(directory).setBare();
} } else {
else
{
builder.setWorkTree(directory); builder.setWorkTree(directory);
} }
return builder.build(); return builder.build();
} }
/** public static void release(DiffFormatter formatter) {
* Method description if (formatter != null) {
*
*
* @param formatter
*/
public static void release(DiffFormatter formatter)
{
if (formatter != null)
{
formatter.close(); formatter.close();
} }
} }
/** public static void release(TreeWalk walk) {
* Method description if (walk != null) {
*
*
* @param walk
*/
public static void release(TreeWalk walk)
{
if (walk != null)
{
walk.close(); walk.close();
} }
} }
/** public static void release(RevWalk walk) {
* Method description if (walk != null) {
*
*
* @param walk
*/
public static void release(RevWalk walk)
{
if (walk != null)
{
walk.close(); walk.close();
} }
} }
//~--- get methods ---------------------------------------------------------- //~--- get methods ----------------------------------------------------------
/** public static String getBranch(Ref ref) {
* Method description
*
*
* @param ref
*
* @return
*/
public static String getBranch(Ref ref)
{
String branch = null; String branch = null;
if (ref != null) if (ref != null) {
{
branch = getBranch(ref.getName()); branch = getBranch(ref.getName());
} }
return branch; return branch;
} }
/** public static String getBranch(String name) {
* Method description
*
*
* @param name
*
* @return
*/
public static String getBranch(String name)
{
String branch = null; 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()); 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. * Returns {@code true} if the provided reference name is a branch name.
* *
* @param refName reference name * @param refName reference name
*
* @return {@code true} if the name is a branch name * @return {@code true} if the name is a branch name
*
* @since 1.50 * @since 1.50
*/ */
public static boolean isBranch(String refName) public static boolean isBranch(String refName) {
{
return Strings.nullToEmpty(refName).startsWith(PREFIX_HEADS); return Strings.nullToEmpty(refName).startsWith(PREFIX_HEADS);
} }
public static Ref getBranchIdOrCurrentHead(org.eclipse.jgit.lib.Repository gitRepository, String requestedBranch) throws IOException { 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"); logger.trace("no default branch configured, use repository head as default");
Optional<Ref> repositoryHeadRef = GitUtil.getRepositoryHeadRef(gitRepository); Optional<Ref> repositoryHeadRef = GitUtil.getRepositoryHeadRef(gitRepository);
return repositoryHeadRef.orElse(null); return repositoryHeadRef.orElse(null);
@@ -352,37 +243,28 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param repo * @param repo
* @param branchName * @param branchName
*
* @return * @return
*
* @throws IOException * @throws IOException
*/ */
public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo, public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo,
String branchName) String branchName)
throws IOException throws IOException {
{
Ref ref = null; Ref ref = null;
if (!branchName.startsWith(REF_HEAD)) if (!branchName.startsWith(REF_HEAD)) {
{
branchName = PREFIX_HEADS.concat(branchName); branchName = PREFIX_HEADS.concat(branchName);
} }
checkBranchName(repo, branchName); checkBranchName(repo, branchName);
try try {
{
ref = repo.findRef(branchName); ref = repo.findRef(branchName);
if (ref == null) if (ref == null) {
{
logger.warn("could not find branch for {}", branchName); logger.warn("could not find branch for {}", branchName);
} }
} } catch (IOException ex) {
catch (IOException ex)
{
logger.warn("error occured during resolve of branch id", ex); logger.warn("error occured during resolve of branch id", ex);
} }
@@ -415,33 +297,21 @@ public final class GitUtil
} }
/** /**
* Method description * 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.
*
* @param repository
* @param revWalk
* @param ref
*
* @return
*
* @throws IOException
*/ */
public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository, public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository,
RevWalk revWalk, Ref ref) RevWalk revWalk, Ref ref)
throws IOException throws IOException {
{
RevCommit commit = null; RevCommit commit = null;
ObjectId id = ref.getPeeledObjectId(); ObjectId id = ref.getPeeledObjectId();
if (id == null) if (id == null) {
{
id = ref.getObjectId(); id = ref.getObjectId();
} }
if (id != null) if (id != null) {
{ if (revWalk == null) {
if (revWalk == null)
{
revWalk = new RevWalk(repository); revWalk = new RevWalk(repository);
} }
@@ -451,16 +321,30 @@ public final class GitUtil
return commit; 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 * Method description
* *
*
* @param commit * @param commit
*
* @return * @return
*/ */
public static long getCommitTime(RevCommit commit) public static long getCommitTime(RevCommit commit) {
{
long date = commit.getCommitTime(); long date = commit.getCommitTime();
date = date * 1000; date = date * 1000;
@@ -471,17 +355,13 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param objectId * @param objectId
*
* @return * @return
*/ */
public static String getId(AnyObjectId objectId) public static String getId(AnyObjectId objectId) {
{
String id = Util.EMPTY_STRING; String id = Util.EMPTY_STRING;
if (objectId != null) if (objectId != null) {
{
id = objectId.name(); id = objectId.name();
} }
@@ -491,44 +371,27 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param repository * @param repository
* @param id * @param id
*
* @return * @return
*
* @throws IOException * @throws IOException
*/ */
public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository, public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository,
ObjectId id) ObjectId id)
throws IOException throws IOException {
{
Ref ref = null; Ref ref = null;
RevWalk walk = null;
try
{
walk = new RevWalk(repository);
try (RevWalk walk = new RevWalk(repository)) {
RevCommit commit = walk.parseCommit(id); RevCommit commit = walk.parseCommit(id);
for (Map.Entry<String, Ref> e : repository.getAllRefs().entrySet()) for (Map.Entry<String, Ref> e : repository.getAllRefs().entrySet()) {
{ if (e.getKey().startsWith(Constants.R_HEADS) && walk.isMergedInto(commit,
if (e.getKey().startsWith(Constants.R_HEADS)) walk.parseCommit(e.getValue().getObjectId()))) {
{ ref = e.getValue();
if (walk.isMergedInto(commit,
walk.parseCommit(e.getValue().getObjectId())))
{
ref = e.getValue();
}
} }
} }
} }
finally
{
release(walk);
}
return ref; return ref;
} }
@@ -580,26 +443,19 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param repo * @param repo
* @param revision * @param revision
*
* @return * @return
*
* @throws IOException * @throws IOException
*/ */
public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo, public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo,
String revision) String revision)
throws IOException throws IOException {
{
ObjectId revId; ObjectId revId;
if (Util.isNotEmpty(revision)) if (Util.isNotEmpty(revision)) {
{
revId = repo.resolve(revision); revId = repo.resolve(revision);
} } else {
else
{
revId = getRepositoryHead(repo); revId = getRepositoryHead(repo);
} }
@@ -609,34 +465,27 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param repository * @param repository
* @param localBranch * @param localBranch
*
* @return * @return
*/ */
public static String getScmRemoteRefName(Repository repository, public static String getScmRemoteRefName(Repository repository,
Ref localBranch) Ref localBranch) {
{
return getScmRemoteRefName(repository, localBranch.getName()); return getScmRemoteRefName(repository, localBranch.getName());
} }
/** /**
* Method description * Method description
* *
*
* @param repository * @param repository
* @param localBranch * @param localBranch
*
* @return * @return
*/ */
public static String getScmRemoteRefName(Repository repository, public static String getScmRemoteRefName(Repository repository,
String localBranch) String localBranch) {
{
String branch = localBranch; String branch = localBranch;
if (localBranch.startsWith(REF_HEAD_PREFIX)) if (localBranch.startsWith(REF_HEAD_PREFIX)) {
{
branch = localBranch.substring(REF_HEAD_PREFIX.length()); 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. * Returns the name of the tag or {@code null} if the the ref is not a tag.
* *
* @param refName ref name * @param refName ref name
*
* @return name of tag or {@link null} * @return name of tag or {@link null}
*
* @since 1.50 * @since 1.50
*/ */
public static String getTagName(String refName) public static String getTagName(String refName) {
{
String tagName = null; String tagName = null;
if (refName.startsWith(PREFIX_TAG)) if (refName.startsWith(PREFIX_TAG)) {
{
tagName = refName.substring(PREFIX_TAG.length()); tagName = refName.substring(PREFIX_TAG.length());
} }
@@ -666,91 +511,112 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param ref * @param ref
*
* @return * @return
*/ */
public static String getTagName(Ref ref) public static String getTagName(Ref ref) {
{
String name = ref.getName(); String name = ref.getName();
if (name.startsWith(PREFIX_TAG)) if (name.startsWith(PREFIX_TAG)) {
{
name = name.substring(PREFIX_TAG.length()); name = name.substring(PREFIX_TAG.length());
} }
return name; return name;
} }
private static final String GPG_HEADER = "-----BEGIN PGP SIGNATURE-----";
public static Optional<Signature> 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<PublicKey> 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. * Returns true if the request comes from a git client.
* *
*
* @param request servlet request * @param request servlet request
*
* @return true if the client is git * @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; return GIT_USER_AGENT_PROVIDER.parseUserAgent(request.getHeader(HttpUtil.HEADER_USERAGENT)) != null;
} }
/** /**
* Method description * Method description
* *
*
* @param dir * @param dir
*
* @return * @return
*/ */
public static boolean isGitDirectory(File dir) public static boolean isGitDirectory(File dir) {
{
return isGitDirectory(FS.DETECTED, dir); return isGitDirectory(FS.DETECTED, dir);
} }
/** /**
* Method description * Method description
* *
*
* @param fs * @param fs
* @param dir * @param dir
*
* @return * @return
*/ */
public static boolean isGitDirectory(FS fs, File dir) public static boolean isGitDirectory(FS fs, File dir) {
{
//J- //J-
return fs.resolve(dir, DIRECTORY_OBJETCS).exists() return fs.resolve(dir, DIRECTORY_OBJETCS).exists()
&& fs.resolve(dir, DIRECTORY_REFS).exists() && fs.resolve(dir, DIRECTORY_REFS).exists()
&&!fs.resolve(dir, DIRECTORY_DOTGIT).exists(); && !fs.resolve(dir, DIRECTORY_DOTGIT).exists();
//J+ //J+
} }
/** /**
* Method description * Method description
* *
*
* @param ref * @param ref
*
* @return * @return
*/ */
public static boolean isHead(String ref) public static boolean isHead(String ref) {
{
return ref.startsWith(REF_HEAD_PREFIX); return ref.startsWith(REF_HEAD_PREFIX);
} }
/** /**
* Method description * Method description
* *
*
* @param id * @param id
*
* @return * @return
*/ */
public static boolean isValidObjectId(ObjectId id) public static boolean isValidObjectId(ObjectId id) {
{ return (id != null) && !id.equals(ObjectId.zeroId());
return (id != null) &&!id.equals(ObjectId.zeroId());
} }
/** /**
@@ -792,25 +658,20 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param repo * @param repo
* @param branchName * @param branchName
*
* @throws IOException * @throws IOException
*/ */
@VisibleForTesting @VisibleForTesting
static void checkBranchName(org.eclipse.jgit.lib.Repository repo, static void checkBranchName(org.eclipse.jgit.lib.Repository repo,
String branchName) String branchName)
throws IOException throws IOException {
{ if (branchName.contains("..")) {
if (branchName.contains(".."))
{
File repoDirectory = repo.getDirectory(); File repoDirectory = repo.getDirectory();
File branchFile = new File(repoDirectory, branchName); File branchFile = new File(repoDirectory, branchName);
if (!branchFile.getCanonicalPath().startsWith( if (!branchFile.getCanonicalPath().startsWith(
repoDirectory.getCanonicalPath())) repoDirectory.getCanonicalPath())) {
{
logger.error( logger.error(
"branch \"{}\" is outside of the repository. It looks like path traversal attack", "branch \"{}\" is outside of the repository. It looks like path traversal attack",
branchName); branchName);
@@ -824,13 +685,10 @@ public final class GitUtil
/** /**
* Method description * Method description
* *
*
* @param repository * @param repository
*
* @return * @return
*/ */
private static RefSpec createRefSpec(Repository repository) private static RefSpec createRefSpec(Repository repository) {
{
return new RefSpec(String.format(REFSPEC, repository.getId())); return new RefSpec(String.format(REFSPEC, repository.getId()));
} }
} }

View File

@@ -47,6 +47,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
Command.DIFF, Command.DIFF,
Command.DIFF_RESULT, Command.DIFF_RESULT,
Command.LOG, Command.LOG,
Command.TAG,
Command.TAGS, Command.TAGS,
Command.BRANCH, Command.BRANCH,
Command.BRANCHES, Command.BRANCHES,
@@ -142,7 +143,12 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
@Override @Override
public TagsCommand getTagsCommand() { public TagsCommand getTagsCommand() {
return new GitTagsCommand(context); return commandInjector.getInstance(GitTagsCommand.class);
}
@Override
public TagCommand getTagCommand() {
return commandInjector.getInstance(GitTagCommand.class);
} }
@Override @Override

View File

@@ -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<Ref> 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<Ref> 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<Tag> newTags;
private final List<Tag> deletedTags;
private TagHookContextProvider(List<Tag> newTags, List<Tag> 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<HookFeature> getSupportedFeatures() {
return singleton(HookFeature.TAG_PROVIDER);
}
@Override
public HookTagProvider getTagProvider() {
return new HookTagProvider() {
@Override
public List<Tag> getCreatedTags() {
return newTags;
}
@Override
public List<Tag> getDeletedTags() {
return deletedTags;
}
};
}
@Override
public HookChangesetProvider getChangesetProvider() {
return r -> new HookChangesetResponse(emptyList());
}
}
}

View File

@@ -30,15 +30,21 @@ import com.google.common.base.Function;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.Ref; 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.RevObject;
import org.eclipse.jgit.revwalk.RevTag;
import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.revwalk.RevWalk;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitUtil; import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Tag; import sonia.scm.repository.Tag;
import sonia.scm.security.GPG;
import javax.inject.Inject;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@@ -49,32 +55,34 @@ import java.util.List;
*/ */
public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
private final GPG gpg;
/** /**
* Constructs ... * Constructs ...
* *
* @param context * @param context
*/ */
public GitTagsCommand(GitContext context) { @Inject
public GitTagsCommand(GitContext context, GPG gpp) {
super(context); super(context);
this.gpg = gpp;
} }
//~--- get methods ---------------------------------------------------------- //~--- get methods ----------------------------------------------------------
@Override @Override
public List<Tag> getTags() throws IOException { public List<Tag> getTags() throws IOException {
List<Tag> tags = null; List<Tag> tags;
RevWalk revWalk = null; RevWalk revWalk = null;
try { try (Git git = new Git(open())) {
final Git git = new Git(open());
revWalk = new RevWalk(git.getRepository()); revWalk = new RevWalk(git.getRepository());
List<Ref> tagList = git.tagList().call(); List<Ref> tagList = git.tagList().call();
tags = Lists.transform(tagList, tags = Lists.transform(tagList,
new TransformFuntion(git.getRepository(), revWalk)); new TransformFunction(git.getRepository(), revWalk, gpg));
} catch (GitAPIException ex) { } catch (GitAPIException ex) {
throw new InternalRepositoryException(repository, "could not read tags from repository", ex); throw new InternalRepositoryException(repository, "could not read tags from repository", ex);
} finally { } finally {
@@ -92,26 +100,27 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
* @author Enter your name here... * @author Enter your name here...
* @version Enter version here..., 12/07/06 * @version Enter version here..., 12/07/06
*/ */
private static class TransformFuntion implements Function<Ref, Tag> { private static class TransformFunction implements Function<Ref, Tag> {
/** /**
* the logger for TransformFuntion * the logger for TransformFuntion
*/ */
private static final Logger logger = private static final Logger logger =
LoggerFactory.getLogger(TransformFuntion.class); LoggerFactory.getLogger(TransformFunction.class);
//~--- constructors ------------------------------------------------------- //~--- constructors -------------------------------------------------------
/** /**
* Constructs ... * Constructs ...
* * @param repository
* @param repository
* @param revWalk * @param revWalk
*/ */
public TransformFuntion(org.eclipse.jgit.lib.Repository repository, public TransformFunction(Repository repository,
RevWalk revWalk) { RevWalk revWalk,
GPG gpg) {
this.repository = repository; this.repository = repository;
this.revWalk = revWalk; this.revWalk = revWalk;
this.gpg = gpg;
} }
//~--- methods ------------------------------------------------------------ //~--- methods ------------------------------------------------------------
@@ -127,14 +136,17 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
Tag tag = null; Tag tag = null;
try { try {
RevObject revObject = GitUtil.getCommit(repository, revWalk, ref); RevCommit revCommit = GitUtil.getCommit(repository, revWalk, ref);
if (revCommit != null) {
if (revObject != null) {
String name = GitUtil.getTagName(ref); String name = GitUtil.getTagName(ref);
tag = new Tag(name, revCommit.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId()));
tag = new Tag(name, revObject.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) { } catch (IOException ex) {
logger.error("could not get commit for tag", ex); logger.error("could not get commit for tag", ex);
} }
@@ -147,11 +159,12 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
/** /**
* Field description * Field description
*/ */
private org.eclipse.jgit.lib.Repository repository; private final org.eclipse.jgit.lib.Repository repository;
/** /**
* Field description * Field description
*/ */
private RevWalk revWalk; private final RevWalk revWalk;
private final GPG gpg;
} }
} }

View File

@@ -32,7 +32,6 @@ import org.mockito.invocation.InvocationOnMock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.Branch; import sonia.scm.repository.Branch;
import sonia.scm.repository.BranchCreatedEvent;
import sonia.scm.repository.PostReceiveRepositoryHookEvent; import sonia.scm.repository.PostReceiveRepositoryHookEvent;
import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.PreReceiveRepositoryHookEvent;
import sonia.scm.repository.api.BranchRequest; import sonia.scm.repository.api.BranchRequest;

View File

@@ -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<Tag> 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<Object> 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<Object> 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> 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<Object> 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<Object> 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<Tag> readTags(GitContext context) throws IOException {
return new GitTagsCommand(context, gpg).getTags();
}
private Optional<Tag> findTag(GitContext context, String name) throws IOException {
List<Tag> 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;
}
}

View File

@@ -24,48 +24,86 @@
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware; import com.github.sdorra.shiro.SubjectAware;
import org.junit.Rule;
import org.junit.Test; 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.repository.Tag;
import sonia.scm.security.GPG;
import sonia.scm.security.PublicKey;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; 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") @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret")
@RunWith(MockitoJUnitRunner.class)
public class GitTagsCommandTest extends AbstractGitCommandTestBase { public class GitTagsCommandTest extends AbstractGitCommandTestBase {
@Rule @Mock
public TemporaryFolder temporaryFolder = new TemporaryFolder(); GPG gpg;
@Rule @Mock
public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); PublicKey publicKey;
@Rule
public ShiroRule shiro = new ShiroRule();
@Test @Test
public void shouldGetDatesCorrectly() throws IOException { public void shouldGetDatesCorrectly() throws IOException {
final GitContext gitContext = createContext(); final GitContext gitContext = createContext();
final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext); final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg);
final List<Tag> tags = tagsCommand.getTags(); final List<Tag> tags = tagsCommand.getTags();
assertThat(tags).hasSize(2); assertThat(tags).hasSize(3);
Tag annotatedTag = tags.get(0); Tag annotatedTag = tags.get(0);
assertThat(annotatedTag.getName()).isEqualTo("1.0.0"); assertThat(annotatedTag.getName()).isEqualTo("1.0.0");
assertThat(annotatedTag.getDate()).contains(1598348105000L); // Annotated - Take tag date assertThat(annotatedTag.getDate()).contains(1598348105000L); // Annotated - Take tag date
assertThat(annotatedTag.getRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); assertThat(annotatedTag.getRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec");
Tag lightweightTag = tags.get(1); Tag lightweightTag = tags.get(2);
assertThat(lightweightTag.getName()).isEqualTo("test-tag"); assertThat(lightweightTag.getName()).isEqualTo("test-tag");
assertThat(lightweightTag.getDate()).contains(1339416344000L); // Lightweight - Take commit date assertThat(lightweightTag.getDate()).contains(1339416344000L); // Lightweight - Take commit date
assertThat(lightweightTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); 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 <arthur.dent@hitchhiker.com> 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<Tag> 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 @Override
protected String getZippedRepositoryResource() { protected String getZippedRepositoryResource() {
return "sonia/scm/repository/spi/scm-git-spi-test-tags.zip"; return "sonia/scm/repository/spi/scm-git-spi-test-tags.zip";

View File

@@ -35,60 +35,28 @@ import sonia.scm.repository.Repository;
public class AbstractCommand public class AbstractCommand
{ {
/** protected final HgCommandContext context;
* Constructs ... protected final Repository repository;
*
* @param context
*
*/
public AbstractCommand(HgCommandContext context) public AbstractCommand(HgCommandContext context)
{ {
this.context = context; this.context = context;
this.repository = context.getScmRepository(); this.repository = context.getScmRepository();
} }
//~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @return
*/
public com.aragost.javahg.Repository open() public com.aragost.javahg.Repository open()
{ {
return context.open(); return context.open();
} }
//~--- get methods ----------------------------------------------------------
/**
* Method description
*
*
* @return
*/
public HgCommandContext getContext() public HgCommandContext getContext()
{ {
return context; return context;
} }
/**
* Method description
*
*
* @return
*/
public Repository getRepository() public Repository getRepository()
{ {
return repository; return repository;
} }
//~--- fields ---------------------------------------------------------------
/** Field description */
private HgCommandContext context;
/** Field description */
private Repository repository;
} }

View File

@@ -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<Changeset> pullChangesIntoCentralRepository(WorkingCopy<Repository, Repository> 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);
}
}
}

View File

@@ -26,7 +26,6 @@ package sonia.scm.repository.spi;
import com.aragost.javahg.Changeset; import com.aragost.javahg.Changeset;
import com.aragost.javahg.commands.CommitCommand; import com.aragost.javahg.commands.CommitCommand;
import com.aragost.javahg.commands.PullCommand;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -37,21 +36,16 @@ import sonia.scm.repository.api.BranchRequest;
import sonia.scm.repository.work.WorkingCopy; import sonia.scm.repository.work.WorkingCopy;
import sonia.scm.user.User; import sonia.scm.user.User;
import java.io.IOException;
/** /**
* Mercurial implementation of the {@link BranchCommand}. * Mercurial implementation of the {@link BranchCommand}.
* Note that this creates an empty commit to "persist" the new branch. * 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 static final Logger LOG = LoggerFactory.getLogger(HgBranchCommand.class);
private final HgWorkingCopyFactory workingCopyFactory;
HgBranchCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) { HgBranchCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) {
super(context); super(context, workingCopyFactory);
this.workingCopyFactory = workingCopyFactory;
} }
@Override @Override
@@ -103,15 +97,4 @@ public class HgBranchCommand extends AbstractCommand implements BranchCommand {
.execute(); .execute();
} }
private void pullChangesIntoCentralRepository(WorkingCopy<com.aragost.javahg.Repository, com.aragost.javahg.Repository> 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);
}
}
} }

View File

@@ -28,7 +28,6 @@ import com.aragost.javahg.Changeset;
import com.aragost.javahg.Repository; import com.aragost.javahg.Repository;
import com.aragost.javahg.commands.CommitCommand; import com.aragost.javahg.commands.CommitCommand;
import com.aragost.javahg.commands.ExecutionException; import com.aragost.javahg.commands.ExecutionException;
import com.aragost.javahg.commands.PullCommand;
import com.aragost.javahg.commands.RemoveCommand; import com.aragost.javahg.commands.RemoveCommand;
import com.aragost.javahg.commands.StatusCommand; import com.aragost.javahg.commands.StatusCommand;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -41,20 +40,17 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; 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 @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); 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) { public HgModifyCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) {
this.context = context; super(context, workingCopyFactory);
this.workingCopyFactory = workingCopyFactory;
} }
@Override @Override
@@ -110,10 +106,10 @@ public class HgModifyCommand implements ModifyCommand {
LOG.trace("commit changes in working copy"); LOG.trace("commit changes in working copy");
CommitCommand.on(workingRepository) CommitCommand.on(workingRepository)
.user(String.format("%s <%s>", request.getAuthor().getName(), request.getAuthor().getMail())) .user(getUserStringFor(request.getAuthor()))
.message(request.getCommitMessage()).execute(); .message(request.getCommitMessage()).execute();
List<Changeset> execute = pullModifyChangesToCentralRepository(request, workingCopy); List<Changeset> execute = pullChangesIntoCentralRepository(workingCopy, request.getBranch());
String node = execute.get(0).getNode(); String node = execute.get(0).getNode();
LOG.debug("successfully pulled changes from working copy, new node {}", node); LOG.debug("successfully pulled changes from working copy, new node {}", node);
@@ -124,24 +120,7 @@ public class HgModifyCommand implements ModifyCommand {
} }
} }
private List<Changeset> pullModifyChangesToCentralRepository(ModifyCommandRequest request, WorkingCopy<com.aragost.javahg.Repository, com.aragost.javahg.Repository> workingCopy) { private void throwInternalRepositoryException(String message, Exception e) {
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) {
throw new InternalRepositoryException(context.getScmRepository(), message, e); throw new InternalRepositoryException(context.getScmRepository(), message, e);
} }
} }

View File

@@ -49,6 +49,7 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider {
Command.DIFF, Command.DIFF,
Command.LOG, Command.LOG,
Command.TAGS, Command.TAGS,
Command.TAG,
Command.BRANCH, Command.BRANCH,
Command.BRANCHES, Command.BRANCHES,
Command.INCOMING, Command.INCOMING,
@@ -261,4 +262,9 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider {
return new HgTagsCommand(context); return new HgTagsCommand(context);
} }
@Override
public TagCommand getTagCommand() {
return new HgTagCommand(context, handler.getWorkingCopyFactory());
}
} }

View File

@@ -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<Repository, Repository> 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<Repository, Repository> 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);
}
}
}

View File

@@ -39,6 +39,8 @@ import java.util.List;
*/ */
public class HgTagsCommand extends AbstractCommand implements TagsCommand { public class HgTagsCommand extends AbstractCommand implements TagsCommand {
public static final String DEFAULT_TAG_NAME = "tip";
/** /**
* Constructs ... * Constructs ...
* *
@@ -99,7 +101,7 @@ public class HgTagsCommand extends AbstractCommand implements TagsCommand {
if ((f != null) && !Strings.isNullOrEmpty(f.getName()) if ((f != null) && !Strings.isNullOrEmpty(f.getName())
&& (f.getChangeset() != null)) { && (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; return t;

View File

@@ -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());
}
}

View File

@@ -184,14 +184,14 @@ public class HgModifyCommandTest extends AbstractHgCommandTestBase {
@Test @Test
public void shouldExtractSimpleMessage() { 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(); matcher.matches();
assertThat(matcher.group(1)).isEqualTo("This is a simple message"); assertThat(matcher.group(1)).isEqualTo("This is a simple message");
} }
@Test @Test
public void shouldExtractErrorMessage() { 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(); matcher.matches();
assertThat(matcher.group(1)).isEqualTo("This is an error message"); assertThat(matcher.group(1)).isEqualTo("This is an error message");
} }

View File

@@ -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<Tag> 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);
}
}

View File

@@ -94,9 +94,11 @@ class Button extends React.Component<Props> {
<span className="icon is-medium"> <span className="icon is-medium">
<Icon name={icon} color="inherit" /> <Icon name={icon} color="inherit" />
</span> </span>
<span> {(label || children) && (
{label} {children} <span>
</span> {label} {children}
</span>
)}
</button> </button>
); );
} }

View File

@@ -75,6 +75,7 @@ export { default as SyntaxHighlighter } from "./SyntaxHighlighter";
export { default as ErrorBoundary } from "./ErrorBoundary"; export { default as ErrorBoundary } from "./ErrorBoundary";
export { default as OverviewPageActions } from "./OverviewPageActions"; export { default as OverviewPageActions } from "./OverviewPageActions";
export { default as CardColumnGroup } from "./CardColumnGroup"; export { default as CardColumnGroup } from "./CardColumnGroup";
export { default as CreateTagModal } from "./modals/CreateTagModal";
export { default as CardColumn } from "./CardColumn"; export { default as CardColumn } from "./CardColumn";
export { default as CardColumnSmall } from "./CardColumnSmall"; export { default as CardColumnSmall } from "./CardColumnSmall";
export { default as CommaSeparatedList } from "./CommaSeparatedList"; export { default as CommaSeparatedList } from "./CommaSeparatedList";

View File

@@ -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<Props> = ({ t, onClose, tagCreationLink, existingTagsLink, onCreated, onError, revision }) => {
const [newTagName, setNewTagName] = useState("");
const [loading, setLoading] = useState(false);
const [tagNames, setTagNames] = useState<string[]>([]);
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 (
<Modal
title={t("tags.create.title")}
active={true}
body={
<>
<InputField
name="name"
label={t("tags.create.form.field.name.label")}
onChange={val => setNewTagName(val)}
value={newTagName}
validationError={!!validationError}
errorMessage={t(validationError)}
/>
<div className="mt-6">{t("tags.create.hint")}</div>
</>
}
footer={
<>
<Button action={onClose}>{t("tags.create.cancel")}</Button>
<Button
color="success"
action={() => createTag()}
loading={loading}
disabled={!!validationError || newTagName.length === 0}
>
{t("tags.create.confirm")}
</Button>
</>
}
closeFunction={onClose}
/>
);
};
export default withTranslation("repos")(CreateTagModal);

View File

@@ -22,10 +22,11 @@
* SOFTWARE. * SOFTWARE.
*/ */
import {Collection, Link, Links} from "./hal"; import { Collection, Links } from "./hal";
import { Tag } from "./Tags"; import { Tag } from "./Tags";
import { Branch } from "./Branches"; import { Branch } from "./Branches";
import { Person } from "./Person"; import { Person } from "./Person";
import { Signature } from "./Signature";
export type Changeset = Collection & { export type Changeset = Collection & {
id: string; 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 = { export type Contributor = {
person: Person; person: Person;
type: string; type: string;

View File

@@ -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;
};
};

View File

@@ -23,10 +23,12 @@
*/ */
import { Links } from "./hal"; import { Links } from "./hal";
import { Signature } from "./Signature";
export type Tag = { export type Tag = {
name: string; name: string;
revision: string; revision: string;
date?: Date; date?: Date;
signatures: Signature[];
_links: Links; _links: Links;
}; };

View File

@@ -29,14 +29,24 @@ export { Me } from "./Me";
export { DisplayedUser, User } from "./User"; export { DisplayedUser, User } from "./User";
export { Group, Member } from "./Group"; export { Group, Member } from "./Group";
export { Repository, RepositoryCollection, RepositoryGroup, RepositoryCreation, Namespace, NamespaceCollection, RepositoryUrlImport } from "./Repositories"; export {
Repository,
RepositoryCollection,
RepositoryGroup,
RepositoryCreation,
Namespace,
NamespaceCollection,
RepositoryUrlImport
} from "./Repositories";
export { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes"; export { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes";
export { Branch, BranchRequest } from "./Branches"; export { Branch, BranchRequest } from "./Branches";
export { Person } from "./Person"; 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"; export { AnnotatedSource, AnnotatedLine } from "./Annotate";

View File

@@ -124,12 +124,41 @@
}, },
"table": { "table": {
"tags": "Tags" "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": { "tag": {
"name": "Name", "name": "Name",
"commit": "Commit", "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": { "code": {
"sources": "Sources", "sources": "Sources",
@@ -179,6 +208,9 @@
"buttons": { "buttons": {
"details": "Details", "details": "Details",
"sources": "Sources" "sources": "Sources"
},
"tag": {
"create": "Tag erstellen"
} }
}, },
"commit": { "commit": {

View File

@@ -108,7 +108,7 @@
"delete": { "delete": {
"button": "Delete branch", "button": "Delete branch",
"subtitle": "Delete branch", "subtitle": "Delete branch",
"description": "Deleted branches can not be restored.", "description": "Deleted branches cannot be restored.",
"confirmAlert": { "confirmAlert": {
"title": "Delete branch", "title": "Delete branch",
"message": "Do you really want to delete the branch \"{{branch}}\"?", "message": "Do you really want to delete the branch \"{{branch}}\"?",
@@ -125,12 +125,41 @@
}, },
"table": { "table": {
"tags": "Tags" "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": { "tag": {
"name": "Name", "name": "Name",
"commit": "Commit", "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": { "code": {
"sources": "Sources", "sources": "Sources",
@@ -180,6 +209,9 @@
"more": "{{count}} more", "more": "{{count}} more",
"count": "{{count}} Contributor", "count": "{{count}} Contributor",
"count_plural": "{{count}} Contributors" "count_plural": "{{count}} Contributors"
},
"tag": {
"create": "Create Tag"
} }
}, },
"commit": { "commit": {

View File

@@ -36,7 +36,7 @@ class BranchView extends React.Component<Props> {
render() { render() {
const { repository, branch } = this.props; const { repository, branch } = this.props;
return ( return (
<div> <>
<BranchDetail repository={repository} branch={branch} /> <BranchDetail repository={repository} branch={branch} />
<hr /> <hr />
<div className="content"> <div className="content">
@@ -50,7 +50,7 @@ class BranchView extends React.Component<Props> {
/> />
</div> </div>
<BranchDangerZone repository={repository} branch={branch} /> <BranchDangerZone repository={repository} branch={branch} />
</div> </>
); );
} }
} }

View File

@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; 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 { import {
AvatarImage, AvatarImage,
AvatarWrapper, AvatarWrapper,
@@ -41,7 +41,10 @@ import {
FileControlFactory, FileControlFactory,
Icon, Icon,
Level, Level,
SignatureIcon SignatureIcon,
Tooltip,
ErrorNotification,
CreateTagModal
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import ContributorTable from "./ContributorTable"; import ContributorTable from "./ContributorTable";
import { Link as ReactLink } from "react-router-dom"; import { Link as ReactLink } from "react-router-dom";
@@ -50,10 +53,7 @@ type Props = WithTranslation & {
changeset: Changeset; changeset: Changeset;
repository: Repository; repository: Repository;
fileControlFactory?: FileControlFactory; fileControlFactory?: FileControlFactory;
}; refetchChangeset?: () => void;
type State = {
collapsed: boolean;
}; };
const RightMarginP = styled.p` const RightMarginP = styled.p`
@@ -82,7 +82,7 @@ const ContributorLine = styled.div`
`; `;
const ContributorColumn = styled.p` const ContributorColumn = styled.p`
flex-grow: 1; flex-grow: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -108,7 +108,6 @@ const ContributorToggleLine = styled.p`
const ChangesetSummary = styled.div` const ChangesetSummary = styled.div`
display: flex; display: flex;
justify-content: space-between;
`; `;
const SeparatedParents = styled.div` const SeparatedParents = styled.div`
@@ -147,7 +146,7 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
<Icon name="angle-right" /> <ChangesetAuthor changeset={changeset} /> <Icon name="angle-right" /> <ChangesetAuthor changeset={changeset} />
</ContributorColumn> </ContributorColumn>
{signatureIcon} {signatureIcon}
<CountColumn className={"is-hidden-mobile"}> <CountColumn className={"is-hidden-mobile is-hidden-tablet-only is-hidden-desktop-only"}>
( (
<span className="has-text-link"> <span className="has-text-link">
{t("changeset.contributors.count", { count: countContributors(changeset) })} {t("changeset.contributors.count", { count: countContributors(changeset) })}
@@ -159,109 +158,131 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
); );
}; };
class ChangesetDetails extends React.Component<Props, State> { const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory, t, refetchChangeset }) => {
constructor(props: Props) { const [collapsed, setCollapsed] = useState(false);
super(props); const [isTagCreationModalVisible, setTagCreationModalVisible] = useState(false);
this.state = { const [error, setError] = useState<Error | undefined>();
collapsed: false
};
}
render() { const description = changesets.parseDescription(changeset.description);
const { changeset, repository, fileControlFactory, t } = this.props; const id = <ChangesetId repository={repository} changeset={changeset} link={false} />;
const { collapsed } = this.state; const date = <DateFromNow date={changeset.date} />;
const parents = changeset._embedded.parents.map((parent: ParentChangeset, index: number) => (
<ReactLink title={parent.id} to={parent.id} key={index}>
{parent.id.substring(0, 7)}
</ReactLink>
));
const showCreateButton = "tag" in changeset._links;
const description = changesets.parseDescription(changeset.description); const collapseDiffs = () => {
const id = <ChangesetId repository={repository} changeset={changeset} link={false} />; setCollapsed(!collapsed);
const date = <DateFromNow date={changeset.date} />;
const parents = changeset._embedded.parents.map((parent: ParentChangeset, index: number) => (
<ReactLink title={parent.id} to={parent.id} key={index}>
{parent.id.substring(0, 7)}
</ReactLink>
));
return (
<>
<div className={classNames("content", "is-marginless")}>
<h4>
<ExtensionPoint
name="changeset.description"
props={{
changeset,
value: description.title
}}
renderAll={false}
>
<ChangesetDescription changeset={changeset} value={description.title} />
</ExtensionPoint>
</h4>
<article className="media">
<AvatarWrapper>
<RightMarginP className={classNames("image", "is-64x64")}>
<AvatarImage person={changeset.author} />
</RightMarginP>
</AvatarWrapper>
<div className="media-content">
<Contributors changeset={changeset} />
<ChangesetSummary className="is-ellipsis-overflow">
<p>
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />
</p>
{parents?.length > 0 && (
<SeparatedParents>
{t("changeset.parents.label", { count: parents?.length }) + ": "}
{parents}
</SeparatedParents>
)}
</ChangesetSummary>
</div>
<div className="media-right">
<ChangesetTags changeset={changeset} />
</div>
</article>
<p>
{description.message.split("\n").map((item, key) => {
return (
<span key={key}>
<ExtensionPoint
name="changeset.description"
props={{
changeset,
value: item
}}
renderAll={false}
>
<ChangesetDescription changeset={changeset} value={item} />
</ExtensionPoint>
<br />
</span>
);
})}
</p>
</div>
<div>
<BottomMarginLevel
right={
<Button
action={this.collapseDiffs}
color="default"
icon={collapsed ? "eye" : "eye-slash"}
label={t("changesets.collapseDiffs")}
reducedMobile={true}
/>
}
/>
<ChangesetDiff changeset={changeset} fileControlFactory={fileControlFactory} defaultCollapse={collapsed} />
</div>
</>
);
}
collapseDiffs = () => {
this.setState(state => ({
collapsed: !state.collapsed
}));
}; };
}
if (error) {
return <ErrorNotification error={error} />;
}
return (
<>
<div className={classNames("content", "is-marginless")}>
<h4>
<ExtensionPoint
name="changeset.description"
props={{
changeset,
value: description.title
}}
renderAll={false}
>
<ChangesetDescription changeset={changeset} value={description.title} />
</ExtensionPoint>
</h4>
<article className="media">
<AvatarWrapper>
<RightMarginP className={classNames("image", "is-64x64")}>
<AvatarImage person={changeset.author} />
</RightMarginP>
</AvatarWrapper>
<div className="media-content">
<Contributors changeset={changeset} />
<ChangesetSummary className="is-ellipsis-overflow">
<p>
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />
</p>
{parents?.length > 0 && (
<SeparatedParents>
{t("changeset.parents.label", { count: parents?.length }) + ": "}
{parents}
</SeparatedParents>
)}
</ChangesetSummary>
</div>
<div className="media-right">
<ChangesetTags changeset={changeset} />
</div>
{showCreateButton && (
<div className="media-right">
<Tooltip message={t("changeset.tag.create")} location="top">
<Button
color="success"
className="tag"
label={(changeset._embedded["tags"]?.length === 0 && t("changeset.tag.create")) || ""}
icon="plus"
action={() => setTagCreationModalVisible(true)}
/>
</Tooltip>
</div>
)}
{isTagCreationModalVisible && (
<CreateTagModal
revision={changeset.id}
onClose={() => setTagCreationModalVisible(false)}
onCreated={() => {
refetchChangeset?.();
setTagCreationModalVisible(false);
}}
onError={setError}
tagCreationLink={(changeset._links["tag"] as Link).href}
existingTagsLink={(repository._links["tags"] as Link).href}
/>
)}
</article>
<p>
{description.message.split("\n").map((item, key) => {
return (
<span key={key}>
<ExtensionPoint
name="changeset.description"
props={{
changeset,
value: item
}}
renderAll={false}
>
<ChangesetDescription changeset={changeset} value={item} />
</ExtensionPoint>
<br />
</span>
);
})}
</p>
</div>
<div>
<BottomMarginLevel
right={
<Button
action={collapseDiffs}
color="default"
icon={collapsed ? "eye" : "eye-slash"}
label={t("changesets.collapseDiffs")}
reducedMobile={true}
/>
}
/>
<ChangesetDiff changeset={changeset} fileControlFactory={fileControlFactory} defaultCollapse={collapsed} />
</div>
</>
);
};
export default withTranslation("repos")(ChangesetDetails); export default withTranslation("repos")(ChangesetDetails);

View File

@@ -29,6 +29,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import { Changeset, Repository } from "@scm-manager/ui-types"; import { Changeset, Repository } from "@scm-manager/ui-types";
import { ErrorPage, Loading } from "@scm-manager/ui-components"; import { ErrorPage, Loading } from "@scm-manager/ui-components";
import { import {
fetchChangeset,
fetchChangesetIfNeeded, fetchChangesetIfNeeded,
getChangeset, getChangeset,
getFetchChangesetFailure, getFetchChangesetFailure,
@@ -45,6 +46,7 @@ type Props = WithTranslation & {
loading: boolean; loading: boolean;
error: Error; error: Error;
fetchChangesetIfNeeded: (repository: Repository, id: string) => void; fetchChangesetIfNeeded: (repository: Repository, id: string) => void;
refetchChangeset: (repository: Repository, id: string) => void;
match: any; match: any;
}; };
@@ -62,7 +64,7 @@ class ChangesetView extends React.Component<Props> {
} }
render() { render() {
const { changeset, loading, error, t, repository, fileControlFactoryFactory } = this.props; const { changeset, loading, error, t, repository, fileControlFactoryFactory, refetchChangeset } = this.props;
if (error) { if (error) {
return <ErrorPage title={t("changesets.errorTitle")} subtitle={t("changesets.errorSubtitle")} error={error} />; return <ErrorPage title={t("changesets.errorTitle")} subtitle={t("changesets.errorSubtitle")} error={error} />;
@@ -75,6 +77,7 @@ class ChangesetView extends React.Component<Props> {
changeset={changeset} changeset={changeset}
repository={repository} repository={repository}
fileControlFactory={fileControlFactoryFactory && fileControlFactoryFactory(changeset)} fileControlFactory={fileControlFactoryFactory && fileControlFactoryFactory(changeset)}
refetchChangeset={() => refetchChangeset(repository, changeset.id)}
/> />
); );
} }
@@ -98,6 +101,9 @@ const mapDispatchToProps = (dispatch: any) => {
return { return {
fetchChangesetIfNeeded: (repository: Repository, id: string) => { fetchChangesetIfNeeded: (repository: Repository, id: string) => {
dispatch(fetchChangesetIfNeeded(repository, id)); dispatch(fetchChangesetIfNeeded(repository, id));
},
refetchChangeset: (repository: Repository, id: string) => {
dispatch(fetchChangeset(repository, id));
} }
}; };
}; };

View File

@@ -25,7 +25,7 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Repository, Tag } from "@scm-manager/ui-types"; 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 styled from "styled-components";
import TagButtonGroup from "./TagButtonGroup"; import TagButtonGroup from "./TagButtonGroup";
@@ -58,9 +58,10 @@ const TagDetail: FC<Props> = ({ tag, repository }) => {
return ( return (
<div className="media"> <div className="media">
<FlexRow className="media-content subtitle"> <FlexRow className="media-content">
<Label>{t("tag.name") + ": "} </Label> {tag.name} <Label className="subtitle has-text-weight-bold has-text-black">{t("tag.name") + ": "} </Label> <span className="subtitle">{tag.name}</span>
<Created className="is-ellipsis-overflow"> <SignatureIcon signatures={tag.signatures} className="ml-2 mb-5" />
<Created className="is-ellipsis-overflow mb-5">
{t("tags.overview.created")} <Date date={tag.date} className="has-text-grey" /> {t("tags.overview.created")} <Date date={tag.date} className="has-text-grey" />
</Created> </Created>
</FlexRow> </FlexRow>

View File

@@ -24,14 +24,16 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { Tag } from "@scm-manager/ui-types"; import { Tag, Link } from "@scm-manager/ui-types";
import styled from "styled-components"; import styled from "styled-components";
import { DateFromNow } from "@scm-manager/ui-components"; import { DateFromNow, Icon } from "@scm-manager/ui-components";
type Props = { type Props = {
tag: Tag; tag: Tag;
baseUrl: string; baseUrl: string;
onDelete: (tag: Tag) => void;
// deleting: boolean;
}; };
const Created = styled.span` const Created = styled.span`
@@ -39,20 +41,32 @@ const Created = styled.span`
font-size: 0.8rem; font-size: 0.8rem;
`; `;
const TagRow: FC<Props> = ({ tag, baseUrl }) => { const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
let deleteButton;
if ((tag?._links?.delete as Link)?.href) {
deleteButton = (
<a className="level-item" onClick={() => onDelete(tag)}>
<span className="icon is-small">
<Icon name="trash" className="fas" title={t("tag.delete.button")} />
</span>
</a>
);
}
const to = `${baseUrl}/${encodeURIComponent(tag.name)}/info`; const to = `${baseUrl}/${encodeURIComponent(tag.name)}/info`;
return ( return (
<tr> <tr>
<td> <td>
<Link to={to} title={tag.name}> <RouterLink to={to} title={tag.name}>
{tag.name} {tag.name}
<Created className="has-text-grey is-ellipsis-overflow"> <Created className="has-text-grey is-ellipsis-overflow">
{t("tags.overview.created")} <DateFromNow date={tag.date} /> {t("tags.overview.created")} <DateFromNow date={tag.date} />
</Created> </Created>
</Link> </RouterLink>
</td> </td>
<td className="is-darker">{deleteButton}</td>
</tr> </tr>
); );
}; };

View File

@@ -22,38 +22,83 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC, useState } from "react";
import { Tag } from "@scm-manager/ui-types"; import { Link, Tag } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TagRow from "./TagRow"; import TagRow from "./TagRow";
import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
type Props = { type Props = {
baseUrl: string; baseUrl: string;
tags: Tag[]; tags: Tag[];
fetchTags: () => void;
}; };
const TagTable: FC<Props> = ({ baseUrl, tags }) => { const TagTable: FC<Props> = ({ baseUrl, tags, fetchTags }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [tagToBeDeleted, setTagToBeDeleted] = useState<Tag | undefined>();
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 = () => { const renderRow = () => {
let rowContent = null; let rowContent = null;
if (tags) { if (tags) {
rowContent = tags.map((tag, index) => { rowContent = tags.map((tag, index) => {
return <TagRow key={index} baseUrl={baseUrl} tag={tag} />; return <TagRow key={index} baseUrl={baseUrl} tag={tag} onDelete={onDelete} />;
}); });
} }
return rowContent; return rowContent;
}; };
const confirmAlert = (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tagToBeDeleted?.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
onClick: () => deleteTag()
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
);
return ( return (
<table className="card-table table is-hoverable is-fullwidth is-word-break"> <>
<thead> {showConfirmAlert && confirmAlert}
<tr> {error && <ErrorNotification error={error} />}
<th>{t("tags.table.tags")}</th> <table className="card-table table is-hoverable is-fullwidth is-word-break">
</tr> <thead>
</thead> <tr>
<tbody>{renderRow()}</tbody> <th>{t("tags.table.tags")}</th>
</table> </tr>
</thead>
<tbody>{renderRow()}</tbody>
</table>
</>
); );
}; };

View File

@@ -26,6 +26,7 @@ import React, { FC } from "react";
import { Repository, Tag } from "@scm-manager/ui-types"; import { Repository, Tag } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import TagDetail from "./TagDetail"; import TagDetail from "./TagDetail";
import TagDangerZone from "../container/TagDangerZone";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -47,6 +48,7 @@ const TagView: FC<Props> = ({ repository, tag }) => {
}} }}
/> />
</div> </div>
<TagDangerZone repository={repository} tag={tag} />
</> </>
); );
}; };

View File

@@ -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<Props> = ({ tag, repository }) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
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 = (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tag.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
);
}
return (
<>
<ErrorNotification error={error} />
{showConfirmAlert && confirmAlert}
<Level
left={
<p>
<strong>{t("tag.delete.subtitle")}</strong>
<br />
{t("tag.delete.description")}
</p>
}
right={<DeleteButton label={t("tag.delete.button")} action={() => setShowConfirmAlert(true)} />}
/>
</>
);
};
export default DeleteTag;

View File

@@ -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<Props> = ({ repository, tag }) => {
const [t] = useTranslation("repos");
const dangerZone = [];
if (tag?._links?.delete) {
dangerZone.push(<DeleteTag repository={repository} tag={tag} key={dangerZone.length} />);
}
if (dangerZone.length === 0) {
return null;
}
return (
<>
<hr />
<Subtitle subtitle={t("tag.dangerZone")} />
<DangerZoneContainer>{dangerZone}</DangerZoneContainer>
</>
);
};
export default TagDangerZone;

View File

@@ -40,7 +40,7 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
const [error, setError] = useState<Error | undefined>(undefined); const [error, setError] = useState<Error | undefined>(undefined);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => { const fetchTags = () => {
const link = (repository._links?.tags as Link)?.href; const link = (repository._links?.tags as Link)?.href;
if (link) { if (link) {
setLoading(true); setLoading(true);
@@ -51,12 +51,16 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
.then(() => setLoading(false)) .then(() => setLoading(false))
.catch(setError); .catch(setError);
} }
};
useEffect(() => {
fetchTags();
}, [repository]); }, [repository]);
const renderTagsTable = () => { const renderTagsTable = () => {
if (!loading && tags?.length > 0) { if (!loading && tags?.length > 0) {
orderTags(tags); orderTags(tags);
return <TagTable baseUrl={baseUrl} tags={tags} />; return <TagTable baseUrl={baseUrl} tags={tags} fetchTags={fetchTags} />;
} }
return <Notification type="info">{t("tags.overview.noTags")}</Notification>; return <Notification type="info">{t("tags.overview.noTags")}</Notification>;
}; };

View File

@@ -354,7 +354,7 @@ public class BranchRootResource {
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("branch") String branch) { @PathParam("branch") String branch) {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
RepositoryPermissions.modify(repositoryService.getRepository()).check(); RepositoryPermissions.push(repositoryService.getRepository()).check();
Optional<Branch> branchToBeDeleted = repositoryService.getBranchesCommand().getBranches().getBranches().stream() Optional<Branch> branchToBeDeleted = repositoryService.getBranchesCommand().getBranches().getBranches().stream()
.filter(b -> b.getName().equalsIgnoreCase(branch)) .filter(b -> b.getName().equalsIgnoreCase(branch))

View File

@@ -34,6 +34,7 @@ import sonia.scm.repository.Changeset;
import sonia.scm.repository.Contributor; import sonia.scm.repository.Contributor;
import sonia.scm.repository.Person; import sonia.scm.repository.Person;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.Signature; import sonia.scm.repository.Signature;
import sonia.scm.repository.api.Command; import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
@@ -116,6 +117,9 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
if (repositoryService.isSupported(Command.TAGS)) { if (repositoryService.isSupported(Command.TAGS)) {
embeddedBuilder.with("tags", tagCollectionToDtoMapper.getMinimalEmbeddedTagDtoList(namespace, name, source.getTags())); 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)) { if (repositoryService.isSupported(Command.BRANCHES)) {
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(repository, embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(repository,
getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId())))); getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId()))));

View File

@@ -451,6 +451,14 @@ class ResourceLinks {
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("get").parameters(tagName).href(); 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) { String all(String namespace, String name) {
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getAll").parameters().href(); return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getAll").parameters().href();
} }

View File

@@ -28,7 +28,7 @@ import com.google.inject.Inject;
import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository;
import sonia.scm.repository.Tag; import sonia.scm.repository.Tag;
import java.util.Collection; import java.util.Collection;
@@ -40,7 +40,6 @@ import static java.util.stream.Collectors.toList;
public class TagCollectionToDtoMapper { public class TagCollectionToDtoMapper {
private final ResourceLinks resourceLinks; private final ResourceLinks resourceLinks;
private final TagToTagDtoMapper tagToTagDtoMapper; private final TagToTagDtoMapper tagToTagDtoMapper;
@@ -50,12 +49,12 @@ public class TagCollectionToDtoMapper {
this.tagToTagDtoMapper = tagToTagDtoMapper; this.tagToTagDtoMapper = tagToTagDtoMapper;
} }
public HalRepresentation map(String namespace, String name, Collection<Tag> tags) { public HalRepresentation map(Collection<Tag> tags, Repository repository) {
return new HalRepresentation(createLinks(namespace, name), embedDtos(getTagDtoList(namespace, name, tags))); return new HalRepresentation(createLinks(repository.getNamespace(), repository.getName()), embedDtos(getTagDtoList(tags, repository)));
} }
public List<TagDto> getTagDtoList(String namespace, String name, Collection<Tag> tags) { public List<TagDto> getTagDtoList(Collection<Tag> tags, Repository repository) {
return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, new NamespaceAndName(namespace, name))).collect(toList()); return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, repository)).collect(toList());
} }
public List<TagDto> getMinimalEmbeddedTagDtoList(String namespace, String name, Collection<String> tags) { public List<TagDto> getMinimalEmbeddedTagDtoList(String namespace, String name, Collection<String> tags) {

View File

@@ -33,6 +33,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import java.time.Instant; import java.time.Instant;
import java.util.List;
@Getter @Getter
@Setter @Setter
@@ -46,6 +47,8 @@ public class TagDto extends HalRepresentation {
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
private Instant date; private Instant date;
private List<SignatureDto> signatures;
TagDto(Links links) { TagDto(Links links) {
super(links); super(links);
} }

View File

@@ -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;
}

View File

@@ -21,12 +21,15 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation; 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.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema; 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 io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
@@ -36,16 +39,22 @@ import sonia.scm.repository.Tag;
import sonia.scm.repository.Tags; import sonia.scm.repository.Tags;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.api.TagCommandBuilder;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.IOException; 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.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound; import static sonia.scm.NotFoundException.notFound;
@@ -54,14 +63,17 @@ public class TagRootResource {
private final RepositoryServiceFactory serviceFactory; private final RepositoryServiceFactory serviceFactory;
private final TagCollectionToDtoMapper tagCollectionToDtoMapper; private final TagCollectionToDtoMapper tagCollectionToDtoMapper;
private final TagToTagDtoMapper tagToTagDtoMapper; private final TagToTagDtoMapper tagToTagDtoMapper;
private final ResourceLinks resourceLinks;
@Inject @Inject
public TagRootResource(RepositoryServiceFactory serviceFactory, public TagRootResource(RepositoryServiceFactory serviceFactory,
TagCollectionToDtoMapper tagCollectionToDtoMapper, TagCollectionToDtoMapper tagCollectionToDtoMapper,
TagToTagDtoMapper tagToTagDtoMapper) { TagToTagDtoMapper tagToTagDtoMapper,
ResourceLinks resourceLinks) {
this.serviceFactory = serviceFactory; this.serviceFactory = serviceFactory;
this.tagCollectionToDtoMapper = tagCollectionToDtoMapper; this.tagCollectionToDtoMapper = tagCollectionToDtoMapper;
this.tagToTagDtoMapper = tagToTagDtoMapper; this.tagToTagDtoMapper = tagToTagDtoMapper;
this.resourceLinks = resourceLinks;
} }
@GET @GET
@@ -89,7 +101,7 @@ public class TagRootResource {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Tags tags = getTags(repositoryService); Tags tags = getTags(repositoryService);
if (tags != null && tags.getTags() != null) { 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 { } else {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Error on getting tag from repository.") .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 @GET
@Path("{tagName}") @Path("{tagName}")
@@ -136,7 +208,7 @@ public class TagRootResource {
.filter(t -> tagName.equals(t.getName())) .filter(t -> tagName.equals(t.getName()))
.findFirst() .findFirst()
.orElseThrow(() -> createNotFoundException(namespace, name, tagName)); .orElseThrow(() -> createNotFoundException(namespace, name, tagName));
return Response.ok(tagToTagDtoMapper.map(tag, namespaceAndName)).build(); return Response.ok(tagToTagDtoMapper.map(tag, repositoryService.getRepository())).build();
} else { } else {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Error on getting tag from repository.") .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) { private NotFoundException createNotFoundException(String namespace, String name, String tagName) {
return notFound(entity("Tag", tagName).in("Repository", namespace + "/" + name)); return notFound(entity("Tag", tagName).in("Repository", namespace + "/" + name));
} }
@@ -155,5 +266,9 @@ public class TagRootResource {
return repositoryService.getTagsCommand().getTags(); return repositoryService.getTagsCommand().getTags();
} }
private boolean tagExists(String tagName, RepositoryService repositoryService) throws IOException {
return getTags(repositoryService)
.getTagByName(tagName) != null;
}
} }

View File

@@ -31,12 +31,12 @@ import org.mapstruct.Mapper;
import org.mapstruct.Mapping; import org.mapstruct.Mapping;
import org.mapstruct.Named; import org.mapstruct.Named;
import org.mapstruct.ObjectFactory; 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.repository.Tag;
import sonia.scm.web.EdisonHalAppender; import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject; import javax.inject.Inject;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@@ -52,17 +52,23 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper {
@Mapping(target = "date", source = "date", qualifiedByName = "mapDate") @Mapping(target = "date", source = "date", qualifiedByName = "mapDate")
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes @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 @ObjectFactory
TagDto createDto(@Context NamespaceAndName namespaceAndName, Tag tag) { TagDto createDto(@Context Repository repository, Tag tag) {
Links.Builder linksBuilder = linkingTo() Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.tag().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getName())) .self(resourceLinks.tag().self(repository.getNamespace(), repository.getName(), tag.getName()))
.single(link("sources", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision()))) .single(link("sources", resourceLinks.source().self(repository.getNamespace(), repository.getName(), tag.getRevision())))
.single(link("changeset", resourceLinks.changeset().self(namespaceAndName.getNamespace(), namespaceAndName.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(); 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()); return new TagDto(linksBuilder.build(), embeddedBuilder.build());
} }

View File

@@ -24,8 +24,8 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.google.inject.util.Providers;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState; import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.ThreadContext; import org.apache.shiro.util.ThreadContext;
@@ -46,17 +46,24 @@ import sonia.scm.repository.Tag;
import sonia.scm.repository.Tags; import sonia.scm.repository.Tags;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.api.TagCommandBuilder;
import sonia.scm.repository.api.TagsCommandBuilder; import sonia.scm.repository.api.TagsCommandBuilder;
import sonia.scm.web.RestDispatcher; import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@Slf4j @Slf4j
@@ -79,6 +86,12 @@ public class TagRootResourceTest extends RepositoryTestBase {
@Mock @Mock
private TagsCommandBuilder tagsCommandBuilder; private TagsCommandBuilder tagsCommandBuilder;
@Mock
private TagCommandBuilder tagCommandBuilder;
@Mock
private TagCommandBuilder.TagCreateCommandBuilder tagCreateCommandBuilder;
@Mock
private TagCommandBuilder.TagDeleteCommandBuilder tagDeleteCommandBuilder;
private TagCollectionToDtoMapper tagCollectionToDtoMapper; private TagCollectionToDtoMapper tagCollectionToDtoMapper;
@InjectMocks @InjectMocks
@@ -89,17 +102,21 @@ public class TagRootResourceTest extends RepositoryTestBase {
@Before @Before
public void prepareEnvironment() throws Exception { public void prepareEnvironment() {
tagCollectionToDtoMapper = new TagCollectionToDtoMapper(resourceLinks, tagToTagDtoMapper); tagCollectionToDtoMapper = new TagCollectionToDtoMapper(resourceLinks, tagToTagDtoMapper);
tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper); tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper, resourceLinks);
dispatcher.addSingletonResource(getRepositoryRootResource()); dispatcher.addSingletonResource(getRepositoryRootResource());
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService);
when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService); when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService);
when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo")); when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo"));
when(repositoryService.getTagsCommand()).thenReturn(tagsCommandBuilder); when(repositoryService.getTagsCommand()).thenReturn(tagsCommandBuilder);
when(repositoryService.getTagCommand()).thenReturn(tagCommandBuilder);
subjectThreadState.bind(); subjectThreadState.bind();
ThreadContext.bind(subject); ThreadContext.bind(subject);
when(subject.isPermitted(any(String.class))).thenReturn(true); 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 @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("\"name\":\"%s\"", tag2)));
assertTrue(response.getContentAsString().contains(String.format("\"revision\":\"%s\"", revision2))); 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();
}
} }

View File

@@ -24,17 +24,29 @@
package sonia.scm.api.v2.resources; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.NamespaceAndName; 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 sonia.scm.repository.Tag;
import java.net.URI; import java.net.URI;
import java.time.Instant; import java.time.Instant;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class TagToTagDtoMapperTest { class TagToTagDtoMapperTest {
@@ -45,25 +57,76 @@ class TagToTagDtoMapperTest {
@InjectMocks @InjectMocks
private TagToTagDtoMapperImpl mapper; private TagToTagDtoMapperImpl mapper;
@Mock
private Subject subject;
@BeforeEach
void setupSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void tearDown() {
ThreadContext.unbindSubject();
}
@Test @Test
void shouldAppendLinks() { void shouldAppendLinks() {
HalEnricherRegistry registry = new HalEnricherRegistry(); HalEnricherRegistry registry = new HalEnricherRegistry();
registry.register(Tag.class, (ctx, appender) -> { registry.register(Tag.class, (ctx, appender) -> {
NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class); Repository repository = ctx.oneRequireByType(Repository.class);
Tag tag = ctx.oneRequireByType(Tag.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); mapper.setRegistry(registry);
TagDto dto = mapper.map(new Tag("1.0.0", "42"), new NamespaceAndName("hitchhiker", "hog")); TagDto dto = mapper.map(new Tag("1.0.0", "42"), RepositoryTestData.createHeartOfGold());
assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/hog/1.0.0"); assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/HeartOfGold/1.0.0");
} }
@Test @Test
void shouldMapDate() { void shouldMapDate() {
final long now = Instant.now().getEpochSecond() * 1000; 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)); 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();
}
} }