Merge branch 'develop' into feature/import_git_from_url
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Show the date of the last commit for branches in the frontend ([#1439](https://github.com/scm-manager/scm-manager/pull/1439))
|
||||
- Unify and add description to key view across user settings ([#1440](https://github.com/scm-manager/scm-manager/pull/1440))
|
||||
- Healthcheck for docker image ([#1428](https://github.com/scm-manager/scm-manager/issues/1428) and [#1454](https://github.com/scm-manager/scm-manager/issues/1454))
|
||||
- Tags can now be added and deleted through the ui ([#1456](https://github.com/scm-manager/scm-manager/pull/1456))
|
||||
- The ui now displays tag signatures ([#1456](https://github.com/scm-manager/scm-manager/pull/1456))
|
||||
- Repository import via URL for git ([#1460](https://github.com/scm-manager/scm-manager/pull/1460))
|
||||
|
||||
### Changed
|
||||
|
||||
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/de/user/repo/assets/repository-code-changeset-with-tag.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 201 KiB |
BIN
docs/de/user/repo/assets/repository-tag-signatures.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
@@ -34,6 +34,19 @@ Beispielsweise wird der Text hitchhiker/HeartOfGold@1a2b3c4 zu einem Link zu dem
|
||||
|
||||

|
||||
|
||||
#### Tags
|
||||
|
||||
Alle Tags eines Changesets werden in der oberen rechten Ecke der Detailseite angezeigt.
|
||||
|
||||

|
||||
|
||||
#### Tags erstellen
|
||||
|
||||
Neue Tags für ein Changeset können direkt in dessen Übersichtsseite erstellt werden.
|
||||
Es muss lediglich ein gewünschter Name angegeben werden, welcher die gleichen Formatierungsbeschränkungen wie Branches erfüllt.
|
||||
|
||||

|
||||
|
||||
### Datei Details
|
||||
Nach einem Klick auf eine Datei in den Sources landet man in der Detailansicht der Datei. Dabei sind je nach Dateiformat unterschiedliche Ansichten zu sehen:
|
||||
|
||||
|
||||
@@ -11,3 +11,20 @@ Auf der Tags-Übersicht sind die existierenden Tags nach Erstelldatum absteigend
|
||||
Hier wird ein Befehl zum Arbeiten mit dem Tag auf einer Kommandozeile aufgeführt.
|
||||
|
||||

|
||||
|
||||
#### Tag-Signaturen
|
||||
Wenn mindestens eine Signatur für einen Tag existiert, wird der Verifizierungsstatus des Tags als Schlüsselsymbol hinter dessen Namen in der Detailansicht dargestellt.
|
||||
|
||||
Ein Tag kann mehrere Signaturen haben.
|
||||
|
||||
Abhängig vom Status der einzelnen Signaturen, wird das Symbol entsprechend eingefärbt:
|
||||
- wenn mindestens eine Signatur ungültig ist, wird der Schlüssel `rot` dargestellt ANDERNFALLS
|
||||
- wenn mindestens eine Signatur gültigt ist, wird der Schlüssel `grün` dargestellt ANDERNFALLS
|
||||
- wird der Schlüssel `grau` dargestellt
|
||||
|
||||
Wird der Mauszeiger über das Symbol bewegt, erscheint eine Liste aller Signaturen des Tags.
|
||||
|
||||

|
||||
|
||||
### Tags löschen
|
||||
Tags können direkt von der Übersicht aus oder auf der Detailseite gelöscht werden.
|
||||
|
||||
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/en/user/repo/assets/repository-code-changeset-with-tag.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 201 KiB |
BIN
docs/en/user/repo/assets/repository-tag-signatures.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 211 KiB |
@@ -32,9 +32,20 @@ You can expand the diffs gradually or completely by clicking on the blue bars.
|
||||
If commit links formatted like "namespace/name@commitId" are used in the changeset description they will be rendered to internal links.
|
||||
For example the text hitchhiker/HeartOfGold@1a2b3c4 will be transformed to a link directing to the commit 1a2b3c4 of the repository hitchhiker/heartOfGold.
|
||||
|
||||
|
||||

|
||||
|
||||
#### Tags
|
||||
|
||||
All tags for a changeset are displayed in the top-right corner of the details page.
|
||||
|
||||

|
||||
|
||||
#### Creating Tags
|
||||
New tags for a changeset can be created directly on its details page.
|
||||
Only a name has to be provided that meets the same formatting conditions as branches.
|
||||
|
||||

|
||||
|
||||
### File Details
|
||||
After clicking on a file in the sources, the details of the file are shown. Depending on the format of the file, there are different views:
|
||||
|
||||
|
||||
@@ -11,3 +11,20 @@ The tag overview shows the tags that exist for this repository. By clicking on a
|
||||
This page shows a command to work with the tag on the command line.
|
||||
|
||||

|
||||
|
||||
#### Tag Signatures
|
||||
If there is at least one signature on the tag, the verification status is displayed as a key icon after its name on its details page.
|
||||
|
||||
A tag can have multiple signatures.
|
||||
|
||||
Depending on the status of the individual signatures, the key will have a distinct color indicator:
|
||||
- if at least one signature on the tag is invalid, the key will be `red` OTHERWISE
|
||||
- if at least one signature is valid, the key will be `green` OTHERWISE
|
||||
- the key will be `gray`
|
||||
|
||||
If you hover the key icon, a list of all signatures on the tag will pop up.
|
||||
|
||||

|
||||
|
||||
### Deleting Tags
|
||||
Tags can be deleted directly on the tags overview page or on the details page of the tag.
|
||||
|
||||
@@ -28,7 +28,10 @@ import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Represents a tag in a repository.
|
||||
@@ -41,9 +44,14 @@ import java.util.Optional;
|
||||
@Getter
|
||||
public final class Tag {
|
||||
|
||||
public static final String VALID_REV = "[0-9a-z]+";
|
||||
public static final Pattern VALID_REV_PATTERN = Pattern.compile(VALID_REV);
|
||||
|
||||
private final String name;
|
||||
private final String revision;
|
||||
private final Long date;
|
||||
private final List<Signature> signatures = new ArrayList<>();
|
||||
private final Boolean deletable;
|
||||
|
||||
/**
|
||||
* Constructs a new tag.
|
||||
@@ -65,9 +73,24 @@ public final class Tag {
|
||||
* @since 2.5.0
|
||||
*/
|
||||
public Tag(String name, String revision, Long date) {
|
||||
this(name, revision, date, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new tag.
|
||||
*
|
||||
* @param name name of the tag
|
||||
* @param revision tagged revision
|
||||
* @param date the creation timestamp (milliseconds) of the tag
|
||||
* @param deletable whether this tag can be deleted
|
||||
*
|
||||
* @since 2.11.0
|
||||
*/
|
||||
public Tag(String name, String revision, Long date, Boolean deletable) {
|
||||
this.name = name;
|
||||
this.revision = revision;
|
||||
this.date = date;
|
||||
this.deletable = deletable;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,4 +112,8 @@ public final class Tag {
|
||||
public Optional<Long> getDate() {
|
||||
return Optional.ofNullable(date);
|
||||
}
|
||||
|
||||
public void addSignature(Signature signature) {
|
||||
this.signatures.add(signature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,5 +62,10 @@ public enum Command
|
||||
/**
|
||||
* @since 2.10.0
|
||||
*/
|
||||
LOOKUP;
|
||||
LOOKUP,
|
||||
|
||||
/**
|
||||
* @since 2.11.0
|
||||
*/
|
||||
TAG;
|
||||
}
|
||||
|
||||
@@ -377,6 +377,18 @@ public final class RepositoryService implements Closeable {
|
||||
repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag command allows the management of repository tags.
|
||||
*
|
||||
* @return instance of {@link TagCommandBuilder}
|
||||
*
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
*/
|
||||
public TagCommandBuilder getTagCommand() {
|
||||
return new TagCommandBuilder(provider.getTagCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* The unbundle command restores a repository from the given bundle.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -246,6 +246,15 @@ public abstract class RepositoryServiceProvider implements Closeable
|
||||
throw new CommandNotSupportedException(Command.TAGS);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @since 2.11.0
|
||||
*/
|
||||
public TagCommand getTagCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.TAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ public class VndMediaType {
|
||||
public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX;
|
||||
public static final String TAG = PREFIX + "tag" + SUFFIX;
|
||||
public static final String TAG_COLLECTION = PREFIX + "tagCollection" + SUFFIX;
|
||||
public static final String TAG_REQUEST = PREFIX + "tagRequest" + SUFFIX;
|
||||
public static final String BRANCH = PREFIX + "branch" + SUFFIX;
|
||||
public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX;
|
||||
public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX;
|
||||
|
||||
@@ -175,12 +175,12 @@ public class RepositoryAccessITCase {
|
||||
.as("assert tag size")
|
||||
.isNotNull()
|
||||
.size()
|
||||
.isGreaterThan(0);
|
||||
.isPositive();
|
||||
|
||||
assertThat(response.body().jsonPath().getMap("_embedded.tags.find{it.name=='" + tagName + "'}"))
|
||||
.as("assert tag has attributes for name, revision, date and links")
|
||||
.isNotNull()
|
||||
.hasSize(4)
|
||||
.hasSize(5)
|
||||
.containsEntry("name", tagName)
|
||||
.containsEntry("revision", changeset.getId());
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ import org.eclipse.jgit.util.LfsFactory;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.ContextEntry;
|
||||
import sonia.scm.security.GPG;
|
||||
import sonia.scm.security.PublicKey;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.util.Util;
|
||||
import sonia.scm.web.GitUserAgentProvider;
|
||||
@@ -63,6 +65,7 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -73,73 +76,37 @@ import static java.util.Optional.of;
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
*/
|
||||
public final class GitUtil
|
||||
{
|
||||
public final class GitUtil {
|
||||
|
||||
private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider();
|
||||
|
||||
/** Field description */
|
||||
public static final String REF_HEAD = "HEAD";
|
||||
|
||||
/** Field description */
|
||||
public static final String REF_HEAD_PREFIX = "refs/heads/";
|
||||
|
||||
/** Field description */
|
||||
public static final String REF_MASTER = "master";
|
||||
|
||||
/** Field description */
|
||||
private static final String DIRECTORY_DOTGIT = ".git";
|
||||
|
||||
/** Field description */
|
||||
private static final String DIRECTORY_OBJETCS = "objects";
|
||||
|
||||
/** Field description */
|
||||
private static final String DIRECTORY_REFS = "refs";
|
||||
|
||||
/** Field description */
|
||||
private static final String PREFIX_HEADS = "refs/heads/";
|
||||
|
||||
/** Field description */
|
||||
private static final String PREFIX_TAG = "refs/tags/";
|
||||
|
||||
/** Field description */
|
||||
private static final String REFSPEC = "+refs/heads/*:refs/remote/scm/%s/*";
|
||||
|
||||
/** Field description */
|
||||
private static final String REMOTE_REF = "refs/remote/scm/%s/%s";
|
||||
|
||||
/** Field description */
|
||||
private static final int TIMEOUT = 5;
|
||||
|
||||
/** Field description */
|
||||
private static final String USERAGENT_GIT = "git/";
|
||||
|
||||
/** the logger for GitUtil */
|
||||
/**
|
||||
* the logger for GitUtil
|
||||
*/
|
||||
private static final Logger logger = LoggerFactory.getLogger(GitUtil.class);
|
||||
|
||||
//~--- constructors ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
*/
|
||||
private GitUtil() {}
|
||||
private GitUtil() {
|
||||
}
|
||||
|
||||
//~--- methods --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repo
|
||||
*/
|
||||
public static void close(org.eclipse.jgit.lib.Repository repo)
|
||||
{
|
||||
if (repo != null)
|
||||
{
|
||||
public static void close(org.eclipse.jgit.lib.Repository repo) {
|
||||
if (repo != null) {
|
||||
repo.close();
|
||||
}
|
||||
}
|
||||
@@ -147,42 +114,30 @@ public final class GitUtil
|
||||
/**
|
||||
* TODO cache
|
||||
*
|
||||
*
|
||||
* @param repository
|
||||
* @param revWalk
|
||||
*
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static Multimap<ObjectId,
|
||||
String> createTagMap(org.eclipse.jgit.lib.Repository repository,
|
||||
RevWalk revWalk)
|
||||
{
|
||||
RevWalk revWalk) {
|
||||
Multimap<ObjectId, String> tags = ArrayListMultimap.create();
|
||||
|
||||
Map<String, Ref> tagMap = repository.getTags();
|
||||
|
||||
if (tagMap != null)
|
||||
{
|
||||
for (Map.Entry<String, Ref> e : tagMap.entrySet())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tagMap != null) {
|
||||
for (Map.Entry<String, Ref> e : tagMap.entrySet()) {
|
||||
try {
|
||||
|
||||
RevCommit c = getCommit(repository, revWalk, e.getValue());
|
||||
|
||||
if (c != null)
|
||||
{
|
||||
if (c != null) {
|
||||
tags.put(c.getId(), e.getKey());
|
||||
}
|
||||
else if (logger.isWarnEnabled())
|
||||
{
|
||||
} else {
|
||||
logger.warn("could not find commit for tag {}", e.getKey());
|
||||
}
|
||||
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
} catch (IOException ex) {
|
||||
logger.error("could not read commit for ref", ex);
|
||||
}
|
||||
|
||||
@@ -193,8 +148,7 @@ public final class GitUtil
|
||||
}
|
||||
|
||||
public static FetchResult fetch(Git git, File directory, Repository remoteRepository) {
|
||||
try
|
||||
{
|
||||
try {
|
||||
FetchCommand fetch = git.fetch();
|
||||
|
||||
fetch.setRemote(directory.getAbsolutePath());
|
||||
@@ -202,123 +156,63 @@ public final class GitUtil
|
||||
fetch.setTimeout((int) TimeUnit.MINUTES.toSeconds(TIMEOUT));
|
||||
|
||||
return fetch.call();
|
||||
}
|
||||
catch (GitAPIException ex)
|
||||
{
|
||||
} catch (GitAPIException ex) {
|
||||
throw new InternalRepositoryException(ContextEntry.ContextBuilder.entity("Remote", directory.toString()).in(remoteRepository), "could not fetch", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param directory
|
||||
*
|
||||
* @return
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
public static org.eclipse.jgit.lib.Repository open(File directory)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
FS fs = FS.DETECTED;
|
||||
FileRepositoryBuilder builder = new FileRepositoryBuilder();
|
||||
|
||||
builder.setFS(fs);
|
||||
|
||||
if (isGitDirectory(fs, directory))
|
||||
{
|
||||
if (isGitDirectory(fs, directory)) {
|
||||
|
||||
// bare repository
|
||||
builder.setGitDir(directory).setBare();
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
builder.setWorkTree(directory);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param formatter
|
||||
*/
|
||||
public static void release(DiffFormatter formatter)
|
||||
{
|
||||
if (formatter != null)
|
||||
{
|
||||
public static void release(DiffFormatter formatter) {
|
||||
if (formatter != null) {
|
||||
formatter.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param walk
|
||||
*/
|
||||
public static void release(TreeWalk walk)
|
||||
{
|
||||
if (walk != null)
|
||||
{
|
||||
public static void release(TreeWalk walk) {
|
||||
if (walk != null) {
|
||||
walk.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param walk
|
||||
*/
|
||||
public static void release(RevWalk walk)
|
||||
{
|
||||
if (walk != null)
|
||||
{
|
||||
public static void release(RevWalk walk) {
|
||||
if (walk != null) {
|
||||
walk.close();
|
||||
}
|
||||
}
|
||||
|
||||
//~--- get methods ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param ref
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static String getBranch(Ref ref)
|
||||
{
|
||||
public static String getBranch(Ref ref) {
|
||||
String branch = null;
|
||||
|
||||
if (ref != null)
|
||||
{
|
||||
if (ref != null) {
|
||||
branch = getBranch(ref.getName());
|
||||
}
|
||||
|
||||
return branch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param name
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static String getBranch(String name)
|
||||
{
|
||||
public static String getBranch(String name) {
|
||||
String branch = null;
|
||||
|
||||
if (Util.isNotEmpty(name) && name.startsWith(PREFIX_HEADS))
|
||||
{
|
||||
if (Util.isNotEmpty(name) && name.startsWith(PREFIX_HEADS)) {
|
||||
branch = name.substring(PREFIX_HEADS.length());
|
||||
}
|
||||
|
||||
@@ -329,13 +223,10 @@ public final class GitUtil
|
||||
* Returns {@code true} if the provided reference name is a branch name.
|
||||
*
|
||||
* @param refName reference name
|
||||
*
|
||||
* @return {@code true} if the name is a branch name
|
||||
*
|
||||
* @since 1.50
|
||||
*/
|
||||
public static boolean isBranch(String refName)
|
||||
{
|
||||
public static boolean isBranch(String refName) {
|
||||
return Strings.nullToEmpty(refName).startsWith(PREFIX_HEADS);
|
||||
}
|
||||
|
||||
@@ -352,37 +243,28 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repo
|
||||
* @param branchName
|
||||
*
|
||||
* @return
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo,
|
||||
String branchName)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
Ref ref = null;
|
||||
if (!branchName.startsWith(REF_HEAD))
|
||||
{
|
||||
if (!branchName.startsWith(REF_HEAD)) {
|
||||
branchName = PREFIX_HEADS.concat(branchName);
|
||||
}
|
||||
|
||||
checkBranchName(repo, branchName);
|
||||
|
||||
try
|
||||
{
|
||||
try {
|
||||
ref = repo.findRef(branchName);
|
||||
|
||||
if (ref == null)
|
||||
{
|
||||
if (ref == null) {
|
||||
logger.warn("could not find branch for {}", branchName);
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
} catch (IOException ex) {
|
||||
logger.warn("error occured during resolve of branch id", ex);
|
||||
}
|
||||
|
||||
@@ -415,33 +297,21 @@ public final class GitUtil
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repository
|
||||
* @param revWalk
|
||||
* @param ref
|
||||
*
|
||||
* @return
|
||||
*
|
||||
* @throws IOException
|
||||
* Returns the commit for the given ref.
|
||||
* If the given ref is for a tag, the commit that this tag belongs to is returned instead.
|
||||
*/
|
||||
public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository,
|
||||
RevWalk revWalk, Ref ref)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
RevCommit commit = null;
|
||||
ObjectId id = ref.getPeeledObjectId();
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
if (id == null) {
|
||||
id = ref.getObjectId();
|
||||
}
|
||||
|
||||
if (id != null)
|
||||
{
|
||||
if (revWalk == null)
|
||||
{
|
||||
if (id != null) {
|
||||
if (revWalk == null) {
|
||||
revWalk = new RevWalk(repository);
|
||||
}
|
||||
|
||||
@@ -451,16 +321,30 @@ public final class GitUtil
|
||||
return commit;
|
||||
}
|
||||
|
||||
public static RevTag getTag(org.eclipse.jgit.lib.Repository repository,
|
||||
RevWalk revWalk, Ref ref)
|
||||
throws IOException {
|
||||
RevTag tag = null;
|
||||
ObjectId id = ref.getObjectId();
|
||||
|
||||
if (id != null) {
|
||||
if (revWalk == null) {
|
||||
revWalk = new RevWalk(repository);
|
||||
}
|
||||
|
||||
tag = revWalk.parseTag(id);
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param commit
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static long getCommitTime(RevCommit commit)
|
||||
{
|
||||
public static long getCommitTime(RevCommit commit) {
|
||||
long date = commit.getCommitTime();
|
||||
|
||||
date = date * 1000;
|
||||
@@ -471,17 +355,13 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param objectId
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static String getId(AnyObjectId objectId)
|
||||
{
|
||||
public static String getId(AnyObjectId objectId) {
|
||||
String id = Util.EMPTY_STRING;
|
||||
|
||||
if (objectId != null)
|
||||
{
|
||||
if (objectId != null) {
|
||||
id = objectId.name();
|
||||
}
|
||||
|
||||
@@ -491,44 +371,27 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repository
|
||||
* @param id
|
||||
*
|
||||
* @return
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository,
|
||||
ObjectId id)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
Ref ref = null;
|
||||
RevWalk walk = null;
|
||||
|
||||
try
|
||||
{
|
||||
walk = new RevWalk(repository);
|
||||
|
||||
try (RevWalk walk = new RevWalk(repository)) {
|
||||
RevCommit commit = walk.parseCommit(id);
|
||||
|
||||
for (Map.Entry<String, Ref> e : repository.getAllRefs().entrySet())
|
||||
{
|
||||
if (e.getKey().startsWith(Constants.R_HEADS))
|
||||
{
|
||||
if (walk.isMergedInto(commit,
|
||||
walk.parseCommit(e.getValue().getObjectId())))
|
||||
{
|
||||
for (Map.Entry<String, Ref> e : repository.getAllRefs().entrySet()) {
|
||||
if (e.getKey().startsWith(Constants.R_HEADS) && walk.isMergedInto(commit,
|
||||
walk.parseCommit(e.getValue().getObjectId()))) {
|
||||
ref = e.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
release(walk);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -580,26 +443,19 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repo
|
||||
* @param revision
|
||||
*
|
||||
* @return
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo,
|
||||
String revision)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
ObjectId revId;
|
||||
|
||||
if (Util.isNotEmpty(revision))
|
||||
{
|
||||
if (Util.isNotEmpty(revision)) {
|
||||
revId = repo.resolve(revision);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
revId = getRepositoryHead(repo);
|
||||
}
|
||||
|
||||
@@ -609,34 +465,27 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repository
|
||||
* @param localBranch
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static String getScmRemoteRefName(Repository repository,
|
||||
Ref localBranch)
|
||||
{
|
||||
Ref localBranch) {
|
||||
return getScmRemoteRefName(repository, localBranch.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repository
|
||||
* @param localBranch
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static String getScmRemoteRefName(Repository repository,
|
||||
String localBranch)
|
||||
{
|
||||
String localBranch) {
|
||||
String branch = localBranch;
|
||||
|
||||
if (localBranch.startsWith(REF_HEAD_PREFIX))
|
||||
{
|
||||
if (localBranch.startsWith(REF_HEAD_PREFIX)) {
|
||||
branch = localBranch.substring(REF_HEAD_PREFIX.length());
|
||||
}
|
||||
|
||||
@@ -647,16 +496,12 @@ public final class GitUtil
|
||||
* Returns the name of the tag or {@code null} if the the ref is not a tag.
|
||||
*
|
||||
* @param refName ref name
|
||||
*
|
||||
* @return name of tag or {@link null}
|
||||
*
|
||||
* @since 1.50
|
||||
*/
|
||||
public static String getTagName(String refName)
|
||||
{
|
||||
public static String getTagName(String refName) {
|
||||
String tagName = null;
|
||||
if (refName.startsWith(PREFIX_TAG))
|
||||
{
|
||||
if (refName.startsWith(PREFIX_TAG)) {
|
||||
tagName = refName.substring(PREFIX_TAG.length());
|
||||
}
|
||||
|
||||
@@ -666,60 +511,87 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param ref
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static String getTagName(Ref ref)
|
||||
{
|
||||
public static String getTagName(Ref ref) {
|
||||
String name = ref.getName();
|
||||
|
||||
if (name.startsWith(PREFIX_TAG))
|
||||
{
|
||||
if (name.startsWith(PREFIX_TAG)) {
|
||||
name = name.substring(PREFIX_TAG.length());
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private static final String GPG_HEADER = "-----BEGIN PGP SIGNATURE-----";
|
||||
|
||||
public static Optional<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.
|
||||
*
|
||||
*
|
||||
* @param request servlet request
|
||||
*
|
||||
* @return true if the client is git
|
||||
*/
|
||||
public static boolean isGitClient(HttpServletRequest request)
|
||||
{
|
||||
public static boolean isGitClient(HttpServletRequest request) {
|
||||
return GIT_USER_AGENT_PROVIDER.parseUserAgent(request.getHeader(HttpUtil.HEADER_USERAGENT)) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param dir
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static boolean isGitDirectory(File dir)
|
||||
{
|
||||
public static boolean isGitDirectory(File dir) {
|
||||
return isGitDirectory(FS.DETECTED, dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param fs
|
||||
* @param dir
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static boolean isGitDirectory(FS fs, File dir)
|
||||
{
|
||||
public static boolean isGitDirectory(FS fs, File dir) {
|
||||
//J-
|
||||
return fs.resolve(dir, DIRECTORY_OBJETCS).exists()
|
||||
&& fs.resolve(dir, DIRECTORY_REFS).exists()
|
||||
@@ -730,26 +602,20 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param ref
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static boolean isHead(String ref)
|
||||
{
|
||||
public static boolean isHead(String ref) {
|
||||
return ref.startsWith(REF_HEAD_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param id
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static boolean isValidObjectId(ObjectId id)
|
||||
{
|
||||
public static boolean isValidObjectId(ObjectId id) {
|
||||
return (id != null) && !id.equals(ObjectId.zeroId());
|
||||
}
|
||||
|
||||
@@ -792,25 +658,20 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repo
|
||||
* @param branchName
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static void checkBranchName(org.eclipse.jgit.lib.Repository repo,
|
||||
String branchName)
|
||||
throws IOException
|
||||
{
|
||||
if (branchName.contains(".."))
|
||||
{
|
||||
throws IOException {
|
||||
if (branchName.contains("..")) {
|
||||
File repoDirectory = repo.getDirectory();
|
||||
File branchFile = new File(repoDirectory, branchName);
|
||||
|
||||
if (!branchFile.getCanonicalPath().startsWith(
|
||||
repoDirectory.getCanonicalPath()))
|
||||
{
|
||||
repoDirectory.getCanonicalPath())) {
|
||||
logger.error(
|
||||
"branch \"{}\" is outside of the repository. It looks like path traversal attack",
|
||||
branchName);
|
||||
@@ -824,13 +685,10 @@ public final class GitUtil
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param repository
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private static RefSpec createRefSpec(Repository repository)
|
||||
{
|
||||
private static RefSpec createRefSpec(Repository repository) {
|
||||
return new RefSpec(String.format(REFSPEC, repository.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
|
||||
Command.DIFF,
|
||||
Command.DIFF_RESULT,
|
||||
Command.LOG,
|
||||
Command.TAG,
|
||||
Command.TAGS,
|
||||
Command.BRANCH,
|
||||
Command.BRANCHES,
|
||||
@@ -142,7 +143,12 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
|
||||
|
||||
@Override
|
||||
public TagsCommand getTagsCommand() {
|
||||
return new GitTagsCommand(context);
|
||||
return commandInjector.getInstance(GitTagsCommand.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TagCommand getTagCommand() {
|
||||
return commandInjector.getInstance(GitTagCommand.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,21 @@ import com.google.common.base.Function;
|
||||
import com.google.common.collect.Lists;
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.eclipse.jgit.lib.Constants;
|
||||
import org.eclipse.jgit.lib.Ref;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevObject;
|
||||
import org.eclipse.jgit.revwalk.RevTag;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.repository.GitUtil;
|
||||
import sonia.scm.repository.InternalRepositoryException;
|
||||
import sonia.scm.repository.Tag;
|
||||
import sonia.scm.security.GPG;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@@ -49,32 +55,34 @@ import java.util.List;
|
||||
*/
|
||||
public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
|
||||
|
||||
private final GPG gpg;
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
* @param context
|
||||
*/
|
||||
public GitTagsCommand(GitContext context) {
|
||||
@Inject
|
||||
public GitTagsCommand(GitContext context, GPG gpp) {
|
||||
super(context);
|
||||
this.gpg = gpp;
|
||||
}
|
||||
|
||||
//~--- get methods ----------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public List<Tag> getTags() throws IOException {
|
||||
List<Tag> tags = null;
|
||||
List<Tag> tags;
|
||||
|
||||
RevWalk revWalk = null;
|
||||
|
||||
try {
|
||||
final Git git = new Git(open());
|
||||
|
||||
try (Git git = new Git(open())) {
|
||||
revWalk = new RevWalk(git.getRepository());
|
||||
|
||||
List<Ref> tagList = git.tagList().call();
|
||||
|
||||
tags = Lists.transform(tagList,
|
||||
new TransformFuntion(git.getRepository(), revWalk));
|
||||
new TransformFunction(git.getRepository(), revWalk, gpg));
|
||||
} catch (GitAPIException ex) {
|
||||
throw new InternalRepositoryException(repository, "could not read tags from repository", ex);
|
||||
} finally {
|
||||
@@ -92,26 +100,27 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
|
||||
* @author Enter your name here...
|
||||
* @version Enter version here..., 12/07/06
|
||||
*/
|
||||
private static class TransformFuntion implements Function<Ref, Tag> {
|
||||
private static class TransformFunction implements Function<Ref, Tag> {
|
||||
|
||||
/**
|
||||
* the logger for TransformFuntion
|
||||
*/
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(TransformFuntion.class);
|
||||
LoggerFactory.getLogger(TransformFunction.class);
|
||||
|
||||
//~--- constructors -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
* @param repository
|
||||
* @param revWalk
|
||||
*/
|
||||
public TransformFuntion(org.eclipse.jgit.lib.Repository repository,
|
||||
RevWalk revWalk) {
|
||||
public TransformFunction(Repository repository,
|
||||
RevWalk revWalk,
|
||||
GPG gpg) {
|
||||
this.repository = repository;
|
||||
this.revWalk = revWalk;
|
||||
this.gpg = gpg;
|
||||
}
|
||||
|
||||
//~--- methods ------------------------------------------------------------
|
||||
@@ -127,14 +136,17 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
|
||||
Tag tag = null;
|
||||
|
||||
try {
|
||||
RevObject revObject = GitUtil.getCommit(repository, revWalk, ref);
|
||||
|
||||
if (revObject != null) {
|
||||
RevCommit revCommit = GitUtil.getCommit(repository, revWalk, ref);
|
||||
if (revCommit != null) {
|
||||
String name = GitUtil.getTagName(ref);
|
||||
|
||||
tag = new Tag(name, revObject.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId()));
|
||||
tag = new Tag(name, revCommit.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId()));
|
||||
RevObject revObject = revWalk.parseAny(ref.getObjectId());
|
||||
if (revObject.getType() == Constants.OBJ_TAG) {
|
||||
RevTag revTag = (RevTag) revObject;
|
||||
GitUtil.getTagSignature(revTag, gpg, revWalk)
|
||||
.ifPresent(tag::addSignature);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException ex) {
|
||||
logger.error("could not get commit for tag", ex);
|
||||
}
|
||||
@@ -147,11 +159,12 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
|
||||
/**
|
||||
* Field description
|
||||
*/
|
||||
private org.eclipse.jgit.lib.Repository repository;
|
||||
private final org.eclipse.jgit.lib.Repository repository;
|
||||
|
||||
/**
|
||||
* Field description
|
||||
*/
|
||||
private RevWalk revWalk;
|
||||
private final RevWalk revWalk;
|
||||
private final GPG gpg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import org.mockito.invocation.InvocationOnMock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import sonia.scm.event.ScmEventBus;
|
||||
import sonia.scm.repository.Branch;
|
||||
import sonia.scm.repository.BranchCreatedEvent;
|
||||
import sonia.scm.repository.PostReceiveRepositoryHookEvent;
|
||||
import sonia.scm.repository.PreReceiveRepositoryHookEvent;
|
||||
import sonia.scm.repository.api.BranchRequest;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -24,48 +24,86 @@
|
||||
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
import com.github.sdorra.shiro.ShiroRule;
|
||||
import com.github.sdorra.shiro.SubjectAware;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import sonia.scm.repository.SignatureStatus;
|
||||
import sonia.scm.repository.Tag;
|
||||
import sonia.scm.security.GPG;
|
||||
import sonia.scm.security.PublicKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret")
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class GitTagsCommandTest extends AbstractGitCommandTestBase {
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
@Mock
|
||||
GPG gpg;
|
||||
|
||||
@Rule
|
||||
public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule();
|
||||
|
||||
@Rule
|
||||
public ShiroRule shiro = new ShiroRule();
|
||||
@Mock
|
||||
PublicKey publicKey;
|
||||
|
||||
@Test
|
||||
public void shouldGetDatesCorrectly() throws IOException {
|
||||
final GitContext gitContext = createContext();
|
||||
final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext);
|
||||
final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg);
|
||||
final List<Tag> tags = tagsCommand.getTags();
|
||||
assertThat(tags).hasSize(2);
|
||||
assertThat(tags).hasSize(3);
|
||||
|
||||
Tag annotatedTag = tags.get(0);
|
||||
assertThat(annotatedTag.getName()).isEqualTo("1.0.0");
|
||||
assertThat(annotatedTag.getDate()).contains(1598348105000L); // Annotated - Take tag date
|
||||
assertThat(annotatedTag.getRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec");
|
||||
|
||||
Tag lightweightTag = tags.get(1);
|
||||
Tag lightweightTag = tags.get(2);
|
||||
assertThat(lightweightTag.getName()).isEqualTo("test-tag");
|
||||
assertThat(lightweightTag.getDate()).contains(1339416344000L); // Lightweight - Take commit date
|
||||
assertThat(lightweightTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetSignatures() throws IOException {
|
||||
when(gpg.findPublicKeyId(ArgumentMatchers.any())).thenReturn("2BA27721F113C005CC16F06BAE63EFBC49F140CF");
|
||||
when(gpg.findPublicKey("2BA27721F113C005CC16F06BAE63EFBC49F140CF")).thenReturn(Optional.of(publicKey));
|
||||
String signature = "-----BEGIN PGP SIGNATURE-----\n" +
|
||||
"\n" +
|
||||
"iQEzBAABCgAdFiEEK6J3IfETwAXMFvBrrmPvvEnxQM8FAl+9acoACgkQrmPvvEnx\n" +
|
||||
"QM9abwgAnGP+Y/Ijli+PAsimfOmZQWYepjptoOv9m7i3bnHv8V+Qg6cm51I3E0YV\n" +
|
||||
"R2QaxxzW9PgS4hcES+L1qs8Lwo18RurF469eZEmNb8DcUFJ3sEWeHlIl5wZNNo/v\n" +
|
||||
"jJm0d9LNcSmtAIiQ8eDMoGdFXJzHewGickLOSsQGmfZgZus4Qlsh7r3BZTI1Zwd/\n" +
|
||||
"6jaBFctX13FuepCTxq2SjEfRaQHIYkyFQq2o6mjL5S2qfYJ/S//gcCCzxllQrisF\n" +
|
||||
"5fRW3LzLI4eXFH0vua7+UzNS2Rwpifg2OENJA/Kn+3R36LWEGxFK9pNqjVPRAcQj\n" +
|
||||
"1vSkcjK26RqhAqCjNLSagM8ATZrh+g==\n" +
|
||||
"=kUKm\n" +
|
||||
"-----END PGP SIGNATURE-----\n";
|
||||
String signedContent = "object 592d797cd36432e591416e8b2b98154f4f163411\n" +
|
||||
"type commit\n" +
|
||||
"tag signedtag\n" +
|
||||
"tagger Arthur Dent <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
|
||||
protected String getZippedRepositoryResource() {
|
||||
return "sonia/scm/repository/spi/scm-git-spi-test-tags.zip";
|
||||
|
||||
@@ -35,60 +35,28 @@ import sonia.scm.repository.Repository;
|
||||
public class AbstractCommand
|
||||
{
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
* @param context
|
||||
*
|
||||
*/
|
||||
protected final HgCommandContext context;
|
||||
protected final Repository repository;
|
||||
|
||||
public AbstractCommand(HgCommandContext context)
|
||||
{
|
||||
this.context = context;
|
||||
this.repository = context.getScmRepository();
|
||||
}
|
||||
|
||||
//~--- methods --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public com.aragost.javahg.Repository open()
|
||||
{
|
||||
return context.open();
|
||||
}
|
||||
|
||||
//~--- get methods ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public HgCommandContext getContext()
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public Repository getRepository()
|
||||
{
|
||||
return repository;
|
||||
}
|
||||
|
||||
//~--- fields ---------------------------------------------------------------
|
||||
|
||||
/** Field description */
|
||||
private HgCommandContext context;
|
||||
|
||||
/** Field description */
|
||||
private Repository repository;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ package sonia.scm.repository.spi;
|
||||
|
||||
import com.aragost.javahg.Changeset;
|
||||
import com.aragost.javahg.commands.CommitCommand;
|
||||
import com.aragost.javahg.commands.PullCommand;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -37,21 +36,16 @@ import sonia.scm.repository.api.BranchRequest;
|
||||
import sonia.scm.repository.work.WorkingCopy;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Mercurial implementation of the {@link BranchCommand}.
|
||||
* Note that this creates an empty commit to "persist" the new branch.
|
||||
*/
|
||||
public class HgBranchCommand extends AbstractCommand implements BranchCommand {
|
||||
public class HgBranchCommand extends AbstractWorkingCopyCommand implements BranchCommand {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HgBranchCommand.class);
|
||||
|
||||
private final HgWorkingCopyFactory workingCopyFactory;
|
||||
|
||||
HgBranchCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) {
|
||||
super(context);
|
||||
this.workingCopyFactory = workingCopyFactory;
|
||||
super(context, workingCopyFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -103,15 +97,4 @@ public class HgBranchCommand extends AbstractCommand implements BranchCommand {
|
||||
.execute();
|
||||
}
|
||||
|
||||
private void pullChangesIntoCentralRepository(WorkingCopy<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.aragost.javahg.Changeset;
|
||||
import com.aragost.javahg.Repository;
|
||||
import com.aragost.javahg.commands.CommitCommand;
|
||||
import com.aragost.javahg.commands.ExecutionException;
|
||||
import com.aragost.javahg.commands.PullCommand;
|
||||
import com.aragost.javahg.commands.RemoveCommand;
|
||||
import com.aragost.javahg.commands.StatusCommand;
|
||||
import org.slf4j.Logger;
|
||||
@@ -41,20 +40,17 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static sonia.scm.repository.spi.UserFormatter.getUserStringFor;
|
||||
|
||||
@SuppressWarnings("java:S3252") // it is ok for javahg classes to access static method of subtype
|
||||
public class HgModifyCommand implements ModifyCommand {
|
||||
public class HgModifyCommand extends AbstractWorkingCopyCommand implements ModifyCommand {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HgModifyCommand.class);
|
||||
static final Pattern HG_MESSAGE_PATTERN = Pattern.compile(".*\\[SCM\\](?: Error:)? (.*)");
|
||||
|
||||
private final HgCommandContext context;
|
||||
private final HgWorkingCopyFactory workingCopyFactory;
|
||||
|
||||
public HgModifyCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) {
|
||||
this.context = context;
|
||||
this.workingCopyFactory = workingCopyFactory;
|
||||
super(context, workingCopyFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -110,10 +106,10 @@ public class HgModifyCommand implements ModifyCommand {
|
||||
|
||||
LOG.trace("commit changes in working copy");
|
||||
CommitCommand.on(workingRepository)
|
||||
.user(String.format("%s <%s>", request.getAuthor().getName(), request.getAuthor().getMail()))
|
||||
.user(getUserStringFor(request.getAuthor()))
|
||||
.message(request.getCommitMessage()).execute();
|
||||
|
||||
List<Changeset> execute = pullModifyChangesToCentralRepository(request, workingCopy);
|
||||
List<Changeset> execute = pullChangesIntoCentralRepository(workingCopy, request.getBranch());
|
||||
|
||||
String node = execute.get(0).getNode();
|
||||
LOG.debug("successfully pulled changes from working copy, new node {}", node);
|
||||
@@ -124,24 +120,7 @@ public class HgModifyCommand implements ModifyCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private List<Changeset> pullModifyChangesToCentralRepository(ModifyCommandRequest request, WorkingCopy<com.aragost.javahg.Repository, com.aragost.javahg.Repository> workingCopy) {
|
||||
LOG.trace("pull changes from working copy");
|
||||
try {
|
||||
com.aragost.javahg.commands.PullCommand pullCommand = PullCommand.on(workingCopy.getCentralRepository());
|
||||
workingCopyFactory.configure(pullCommand);
|
||||
return pullCommand.execute(workingCopy.getDirectory().getAbsolutePath());
|
||||
} catch (ExecutionException e) {
|
||||
throw IntegrateChangesFromWorkdirException
|
||||
.withPattern(HG_MESSAGE_PATTERN)
|
||||
.forMessage(context.getScmRepository(), e.getMessage());
|
||||
} catch (IOException e) {
|
||||
throw new InternalRepositoryException(context.getScmRepository(),
|
||||
String.format("Could not pull modify changes from working copy to central repository for branch %s", request.getBranch()),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private String throwInternalRepositoryException(String message, Exception e) {
|
||||
private void throwInternalRepositoryException(String message, Exception e) {
|
||||
throw new InternalRepositoryException(context.getScmRepository(), message, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider {
|
||||
Command.DIFF,
|
||||
Command.LOG,
|
||||
Command.TAGS,
|
||||
Command.TAG,
|
||||
Command.BRANCH,
|
||||
Command.BRANCHES,
|
||||
Command.INCOMING,
|
||||
@@ -261,4 +262,9 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider {
|
||||
return new HgTagsCommand(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TagCommand getTagCommand() {
|
||||
return new HgTagCommand(context, handler.getWorkingCopyFactory());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import java.util.List;
|
||||
*/
|
||||
public class HgTagsCommand extends AbstractCommand implements TagsCommand {
|
||||
|
||||
public static final String DEFAULT_TAG_NAME = "tip";
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
@@ -99,7 +101,7 @@ public class HgTagsCommand extends AbstractCommand implements TagsCommand {
|
||||
|
||||
if ((f != null) && !Strings.isNullOrEmpty(f.getName())
|
||||
&& (f.getChangeset() != null)) {
|
||||
t = new Tag(f.getName(), f.getChangeset().getNode(), f.getChangeset().getTimestamp().getDate().getTime());
|
||||
t = new Tag(f.getName(), f.getChangeset().getNode(), f.getChangeset().getTimestamp().getDate().getTime(), !f.getName().equals(DEFAULT_TAG_NAME));
|
||||
}
|
||||
|
||||
return t;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -184,14 +184,14 @@ public class HgModifyCommandTest extends AbstractHgCommandTestBase {
|
||||
|
||||
@Test
|
||||
public void shouldExtractSimpleMessage() {
|
||||
Matcher matcher = HgModifyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] This is a simple message");
|
||||
Matcher matcher = AbstractWorkingCopyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] This is a simple message");
|
||||
matcher.matches();
|
||||
assertThat(matcher.group(1)).isEqualTo("This is a simple message");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldExtractErrorMessage() {
|
||||
Matcher matcher = HgModifyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] Error: This is an error message");
|
||||
Matcher matcher = AbstractWorkingCopyCommand.HG_MESSAGE_PATTERN.matcher("[SCM] Error: This is an error message");
|
||||
matcher.matches();
|
||||
assertThat(matcher.group(1)).isEqualTo("This is an error message");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -94,9 +94,11 @@ class Button extends React.Component<Props> {
|
||||
<span className="icon is-medium">
|
||||
<Icon name={icon} color="inherit" />
|
||||
</span>
|
||||
{(label || children) && (
|
||||
<span>
|
||||
{label} {children}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export { default as SyntaxHighlighter } from "./SyntaxHighlighter";
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||
export { default as OverviewPageActions } from "./OverviewPageActions";
|
||||
export { default as CardColumnGroup } from "./CardColumnGroup";
|
||||
export { default as CreateTagModal } from "./modals/CreateTagModal";
|
||||
export { default as CardColumn } from "./CardColumn";
|
||||
export { default as CardColumnSmall } from "./CardColumnSmall";
|
||||
export { default as CommaSeparatedList } from "./CommaSeparatedList";
|
||||
|
||||
109
scm-ui/ui-components/src/modals/CreateTagModal.tsx
Normal 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);
|
||||
@@ -22,10 +22,11 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import {Collection, Link, Links} from "./hal";
|
||||
import { Collection, Links } from "./hal";
|
||||
import { Tag } from "./Tags";
|
||||
import { Branch } from "./Branches";
|
||||
import { Person } from "./Person";
|
||||
import { Signature } from "./Signature";
|
||||
|
||||
export type Changeset = Collection & {
|
||||
id: string;
|
||||
@@ -42,17 +43,6 @@ export type Changeset = Collection & {
|
||||
};
|
||||
};
|
||||
|
||||
export type Signature = {
|
||||
keyId: string;
|
||||
type: string;
|
||||
status: "VERIFIED" | "NOT_FOUND" | "INVALID";
|
||||
owner?: string;
|
||||
contacts?: Person[];
|
||||
_links?: {
|
||||
rawKey?: Link;
|
||||
};
|
||||
}
|
||||
|
||||
export type Contributor = {
|
||||
person: Person;
|
||||
type: string;
|
||||
|
||||
37
scm-ui/ui-types/src/Signature.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -23,10 +23,12 @@
|
||||
*/
|
||||
|
||||
import { Links } from "./hal";
|
||||
import { Signature } from "./Signature";
|
||||
|
||||
export type Tag = {
|
||||
name: string;
|
||||
revision: string;
|
||||
date?: Date;
|
||||
signatures: Signature[];
|
||||
_links: Links;
|
||||
};
|
||||
|
||||
@@ -29,14 +29,24 @@ export { Me } from "./Me";
|
||||
export { DisplayedUser, User } from "./User";
|
||||
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 { Branch, BranchRequest } from "./Branches";
|
||||
|
||||
export { Person } from "./Person";
|
||||
|
||||
export { Changeset, Contributor, ParentChangeset, Signature } from "./Changesets";
|
||||
export { Changeset, Contributor, ParentChangeset } from "./Changesets";
|
||||
|
||||
export { Signature } from "./Signature";
|
||||
|
||||
export { AnnotatedSource, AnnotatedLine } from "./Annotate";
|
||||
|
||||
|
||||
@@ -124,12 +124,41 @@
|
||||
},
|
||||
"table": {
|
||||
"tags": "Tags"
|
||||
},
|
||||
"create": {
|
||||
"form": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Name",
|
||||
"error": {
|
||||
"exists": "Dieser Tag existiert bereits in diesem Repository.",
|
||||
"format": "Der Tag entspricht nicht dem korrekten Format."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Neuen Tag anlegen",
|
||||
"hint": "Der Tag wird automatisch mit ihrem Standardschlüssel vom SCM-Manager signiert.",
|
||||
"confirm": "Tag erstellen",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
"name": "Name",
|
||||
"commit": "Commit",
|
||||
"sources": "Sources"
|
||||
"sources": "Sources",
|
||||
"dangerZone": "Tag löschen",
|
||||
"delete": {
|
||||
"button": "Tag löschen",
|
||||
"subtitle": "Tag löschen",
|
||||
"description": "Gelöschte Tags können nicht wiederhergestellt werden.",
|
||||
"confirmAlert": {
|
||||
"title": "Tag löschen",
|
||||
"message": "Möchten Sie den Tag \"{{tag}}\" wirklich löschen?",
|
||||
"cancel": "Nein",
|
||||
"submit": "Ja"
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"sources": "Sources",
|
||||
@@ -179,6 +208,9 @@
|
||||
"buttons": {
|
||||
"details": "Details",
|
||||
"sources": "Sources"
|
||||
},
|
||||
"tag": {
|
||||
"create": "Tag erstellen"
|
||||
}
|
||||
},
|
||||
"commit": {
|
||||
|
||||
@@ -125,12 +125,41 @@
|
||||
},
|
||||
"table": {
|
||||
"tags": "Tags"
|
||||
},
|
||||
"create": {
|
||||
"form": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Name",
|
||||
"error": {
|
||||
"exists": "This tag already exists in this repository.",
|
||||
"format": "The tag name does not match the correct format."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Create a new tag",
|
||||
"hint": "The tag will be automatically signed with your default key by the SCM-Manager.",
|
||||
"confirm": "Create Tag",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
"name": "Name",
|
||||
"commit": "Commit",
|
||||
"sources": "Sources"
|
||||
"sources": "Sources",
|
||||
"dangerZone": "Delete tag",
|
||||
"delete": {
|
||||
"button": "Delete tag",
|
||||
"subtitle": "Delete tag",
|
||||
"description": "Deleted tag can not be restored.",
|
||||
"confirmAlert": {
|
||||
"title": "Delete tag",
|
||||
"message": "Do you really want to delete the tag \"{{tag}}\"?",
|
||||
"cancel": "No",
|
||||
"submit": "Yes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"sources": "Sources",
|
||||
@@ -180,6 +209,9 @@
|
||||
"more": "{{count}} more",
|
||||
"count": "{{count}} Contributor",
|
||||
"count_plural": "{{count}} Contributors"
|
||||
},
|
||||
"tag": {
|
||||
"create": "Create Tag"
|
||||
}
|
||||
},
|
||||
"commit": {
|
||||
|
||||
@@ -36,7 +36,7 @@ class BranchView extends React.Component<Props> {
|
||||
render() {
|
||||
const { repository, branch } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<BranchDetail repository={repository} branch={branch} />
|
||||
<hr />
|
||||
<div className="content">
|
||||
@@ -50,7 +50,7 @@ class BranchView extends React.Component<Props> {
|
||||
/>
|
||||
</div>
|
||||
<BranchDangerZone repository={repository} branch={branch} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { Changeset, ParentChangeset, Repository } from "@scm-manager/ui-types";
|
||||
import { Changeset, Link, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types";
|
||||
import {
|
||||
AvatarImage,
|
||||
AvatarWrapper,
|
||||
@@ -41,7 +41,10 @@ import {
|
||||
FileControlFactory,
|
||||
Icon,
|
||||
Level,
|
||||
SignatureIcon
|
||||
SignatureIcon,
|
||||
Tooltip,
|
||||
ErrorNotification,
|
||||
CreateTagModal
|
||||
} from "@scm-manager/ui-components";
|
||||
import ContributorTable from "./ContributorTable";
|
||||
import { Link as ReactLink } from "react-router-dom";
|
||||
@@ -50,10 +53,7 @@ type Props = WithTranslation & {
|
||||
changeset: Changeset;
|
||||
repository: Repository;
|
||||
fileControlFactory?: FileControlFactory;
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
refetchChangeset?: () => void;
|
||||
};
|
||||
|
||||
const RightMarginP = styled.p`
|
||||
@@ -82,7 +82,7 @@ const ContributorLine = styled.div`
|
||||
`;
|
||||
|
||||
const ContributorColumn = styled.p`
|
||||
flex-grow: 1;
|
||||
flex-grow: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -108,7 +108,6 @@ const ContributorToggleLine = styled.p`
|
||||
|
||||
const ChangesetSummary = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const SeparatedParents = styled.div`
|
||||
@@ -147,7 +146,7 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
|
||||
<Icon name="angle-right" /> <ChangesetAuthor changeset={changeset} />
|
||||
</ContributorColumn>
|
||||
{signatureIcon}
|
||||
<CountColumn className={"is-hidden-mobile"}>
|
||||
<CountColumn className={"is-hidden-mobile is-hidden-tablet-only is-hidden-desktop-only"}>
|
||||
(
|
||||
<span className="has-text-link">
|
||||
{t("changeset.contributors.count", { count: countContributors(changeset) })}
|
||||
@@ -159,17 +158,10 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
|
||||
);
|
||||
};
|
||||
|
||||
class ChangesetDetails extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { changeset, repository, fileControlFactory, t } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory, t, refetchChangeset }) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [isTagCreationModalVisible, setTagCreationModalVisible] = useState(false);
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
const description = changesets.parseDescription(changeset.description);
|
||||
const id = <ChangesetId repository={repository} changeset={changeset} link={false} />;
|
||||
@@ -179,6 +171,15 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
{parent.id.substring(0, 7)}
|
||||
</ReactLink>
|
||||
));
|
||||
const showCreateButton = "tag" in changeset._links;
|
||||
|
||||
const collapseDiffs = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -218,6 +219,33 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
<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) => {
|
||||
@@ -243,7 +271,7 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
<BottomMarginLevel
|
||||
right={
|
||||
<Button
|
||||
action={this.collapseDiffs}
|
||||
action={collapseDiffs}
|
||||
color="default"
|
||||
icon={collapsed ? "eye" : "eye-slash"}
|
||||
label={t("changesets.collapseDiffs")}
|
||||
@@ -255,13 +283,6 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
collapseDiffs = () => {
|
||||
this.setState(state => ({
|
||||
collapsed: !state.collapsed
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export default withTranslation("repos")(ChangesetDetails);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { Changeset, Repository } from "@scm-manager/ui-types";
|
||||
import { ErrorPage, Loading } from "@scm-manager/ui-components";
|
||||
import {
|
||||
fetchChangeset,
|
||||
fetchChangesetIfNeeded,
|
||||
getChangeset,
|
||||
getFetchChangesetFailure,
|
||||
@@ -45,6 +46,7 @@ type Props = WithTranslation & {
|
||||
loading: boolean;
|
||||
error: Error;
|
||||
fetchChangesetIfNeeded: (repository: Repository, id: string) => void;
|
||||
refetchChangeset: (repository: Repository, id: string) => void;
|
||||
match: any;
|
||||
};
|
||||
|
||||
@@ -62,7 +64,7 @@ class ChangesetView extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { changeset, loading, error, t, repository, fileControlFactoryFactory } = this.props;
|
||||
const { changeset, loading, error, t, repository, fileControlFactoryFactory, refetchChangeset } = this.props;
|
||||
|
||||
if (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}
|
||||
repository={repository}
|
||||
fileControlFactory={fileControlFactoryFactory && fileControlFactoryFactory(changeset)}
|
||||
refetchChangeset={() => refetchChangeset(repository, changeset.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +101,9 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
fetchChangesetIfNeeded: (repository: Repository, id: string) => {
|
||||
dispatch(fetchChangesetIfNeeded(repository, id));
|
||||
},
|
||||
refetchChangeset: (repository: Repository, id: string) => {
|
||||
dispatch(fetchChangeset(repository, id));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import React, { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repository, Tag } from "@scm-manager/ui-types";
|
||||
import { DateFromNow } from "@scm-manager/ui-components";
|
||||
import { DateFromNow, SignatureIcon } from "@scm-manager/ui-components";
|
||||
import styled from "styled-components";
|
||||
import TagButtonGroup from "./TagButtonGroup";
|
||||
|
||||
@@ -58,9 +58,10 @@ const TagDetail: FC<Props> = ({ tag, repository }) => {
|
||||
|
||||
return (
|
||||
<div className="media">
|
||||
<FlexRow className="media-content subtitle">
|
||||
<Label>{t("tag.name") + ": "} </Label> {tag.name}
|
||||
<Created className="is-ellipsis-overflow">
|
||||
<FlexRow className="media-content">
|
||||
<Label className="subtitle has-text-weight-bold has-text-black">{t("tag.name") + ": "} </Label> <span className="subtitle">{tag.name}</span>
|
||||
<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" />
|
||||
</Created>
|
||||
</FlexRow>
|
||||
|
||||
@@ -24,14 +24,16 @@
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Tag } from "@scm-manager/ui-types";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Tag, Link } from "@scm-manager/ui-types";
|
||||
import styled from "styled-components";
|
||||
import { DateFromNow } from "@scm-manager/ui-components";
|
||||
import { DateFromNow, Icon } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
tag: Tag;
|
||||
baseUrl: string;
|
||||
onDelete: (tag: Tag) => void;
|
||||
// deleting: boolean;
|
||||
};
|
||||
|
||||
const Created = styled.span`
|
||||
@@ -39,20 +41,32 @@ const Created = styled.span`
|
||||
font-size: 0.8rem;
|
||||
`;
|
||||
|
||||
const TagRow: FC<Props> = ({ tag, baseUrl }) => {
|
||||
const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
|
||||
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`;
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Link to={to} title={tag.name}>
|
||||
<RouterLink to={to} title={tag.name}>
|
||||
{tag.name}
|
||||
<Created className="has-text-grey is-ellipsis-overflow">
|
||||
{t("tags.overview.created")} <DateFromNow date={tag.date} />
|
||||
</Created>
|
||||
</Link>
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td className="is-darker">{deleteButton}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,30 +22,74 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { Tag } from "@scm-manager/ui-types";
|
||||
import React, { FC, useState } from "react";
|
||||
import { Link, Tag } from "@scm-manager/ui-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TagRow from "./TagRow";
|
||||
import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
baseUrl: string;
|
||||
tags: Tag[];
|
||||
fetchTags: () => void;
|
||||
};
|
||||
|
||||
const TagTable: FC<Props> = ({ baseUrl, tags }) => {
|
||||
const TagTable: FC<Props> = ({ baseUrl, tags, fetchTags }) => {
|
||||
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 = () => {
|
||||
let rowContent = null;
|
||||
if (tags) {
|
||||
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;
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
{showConfirmAlert && confirmAlert}
|
||||
{error && <ErrorNotification error={error} />}
|
||||
<table className="card-table table is-hoverable is-fullwidth is-word-break">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -54,6 +98,7 @@ const TagTable: FC<Props> = ({ baseUrl, tags }) => {
|
||||
</thead>
|
||||
<tbody>{renderRow()}</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import React, { FC } from "react";
|
||||
import { Repository, Tag } from "@scm-manager/ui-types";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import TagDetail from "./TagDetail";
|
||||
import TagDangerZone from "../container/TagDangerZone";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
@@ -47,6 +48,7 @@ const TagView: FC<Props> = ({ repository, tag }) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TagDangerZone repository={repository} tag={tag} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
93
scm-ui/ui-webapp/src/repos/tags/container/DeleteTag.tsx
Normal 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;
|
||||
59
scm-ui/ui-webapp/src/repos/tags/container/TagDangerZone.tsx
Normal 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;
|
||||
@@ -40,7 +40,7 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = () => {
|
||||
const link = (repository._links?.tags as Link)?.href;
|
||||
if (link) {
|
||||
setLoading(true);
|
||||
@@ -51,12 +51,16 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
|
||||
.then(() => setLoading(false))
|
||||
.catch(setError);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, [repository]);
|
||||
|
||||
const renderTagsTable = () => {
|
||||
if (!loading && tags?.length > 0) {
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -354,7 +354,7 @@ public class BranchRootResource {
|
||||
@PathParam("name") String name,
|
||||
@PathParam("branch") String branch) {
|
||||
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
|
||||
RepositoryPermissions.modify(repositoryService.getRepository()).check();
|
||||
RepositoryPermissions.push(repositoryService.getRepository()).check();
|
||||
|
||||
Optional<Branch> branchToBeDeleted = repositoryService.getBranchesCommand().getBranches().getBranches().stream()
|
||||
.filter(b -> b.getName().equalsIgnoreCase(branch))
|
||||
|
||||
@@ -34,6 +34,7 @@ import sonia.scm.repository.Changeset;
|
||||
import sonia.scm.repository.Contributor;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryPermissions;
|
||||
import sonia.scm.repository.Signature;
|
||||
import sonia.scm.repository.api.Command;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
@@ -116,6 +117,9 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
|
||||
if (repositoryService.isSupported(Command.TAGS)) {
|
||||
embeddedBuilder.with("tags", tagCollectionToDtoMapper.getMinimalEmbeddedTagDtoList(namespace, name, source.getTags()));
|
||||
}
|
||||
if (repositoryService.isSupported(Command.TAG) && RepositoryPermissions.push(repository).isPermitted()) {
|
||||
linksBuilder.single(link("tag", resourceLinks.tag().create(namespace, name)));
|
||||
}
|
||||
if (repositoryService.isSupported(Command.BRANCHES)) {
|
||||
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(repository,
|
||||
getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId()))));
|
||||
|
||||
@@ -451,6 +451,14 @@ class ResourceLinks {
|
||||
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("get").parameters(tagName).href();
|
||||
}
|
||||
|
||||
String delete(String namespace, String name, String tagName) {
|
||||
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("delete").parameters(tagName).href();
|
||||
}
|
||||
|
||||
String create(String namespace, String name) {
|
||||
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("create").parameters().href();
|
||||
}
|
||||
|
||||
String all(String namespace, String name) {
|
||||
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getAll").parameters().href();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.google.inject.Inject;
|
||||
import de.otto.edison.hal.Embedded;
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Links;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.Tag;
|
||||
|
||||
import java.util.Collection;
|
||||
@@ -40,7 +40,6 @@ import static java.util.stream.Collectors.toList;
|
||||
|
||||
public class TagCollectionToDtoMapper {
|
||||
|
||||
|
||||
private final ResourceLinks resourceLinks;
|
||||
private final TagToTagDtoMapper tagToTagDtoMapper;
|
||||
|
||||
@@ -50,12 +49,12 @@ public class TagCollectionToDtoMapper {
|
||||
this.tagToTagDtoMapper = tagToTagDtoMapper;
|
||||
}
|
||||
|
||||
public HalRepresentation map(String namespace, String name, Collection<Tag> tags) {
|
||||
return new HalRepresentation(createLinks(namespace, name), embedDtos(getTagDtoList(namespace, name, tags)));
|
||||
public HalRepresentation map(Collection<Tag> tags, Repository repository) {
|
||||
return new HalRepresentation(createLinks(repository.getNamespace(), repository.getName()), embedDtos(getTagDtoList(tags, repository)));
|
||||
}
|
||||
|
||||
public List<TagDto> getTagDtoList(String namespace, String name, Collection<Tag> tags) {
|
||||
return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, new NamespaceAndName(namespace, name))).collect(toList());
|
||||
public List<TagDto> getTagDtoList(Collection<Tag> tags, Repository repository) {
|
||||
return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, repository)).collect(toList());
|
||||
}
|
||||
|
||||
public List<TagDto> getMinimalEmbeddedTagDtoList(String namespace, String name, Collection<String> tags) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -46,6 +47,8 @@ public class TagDto extends HalRepresentation {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private Instant date;
|
||||
|
||||
private List<SignatureDto> signatures;
|
||||
|
||||
TagDto(Links links) {
|
||||
super(links);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -25,8 +25,11 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.headers.Header;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
@@ -36,16 +39,22 @@ import sonia.scm.repository.Tag;
|
||||
import sonia.scm.repository.Tags;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.repository.api.TagCommandBuilder;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.validation.Valid;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.Response;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
import static sonia.scm.AlreadyExistsException.alreadyExists;
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
import static sonia.scm.NotFoundException.notFound;
|
||||
|
||||
@@ -54,14 +63,17 @@ public class TagRootResource {
|
||||
private final RepositoryServiceFactory serviceFactory;
|
||||
private final TagCollectionToDtoMapper tagCollectionToDtoMapper;
|
||||
private final TagToTagDtoMapper tagToTagDtoMapper;
|
||||
private final ResourceLinks resourceLinks;
|
||||
|
||||
@Inject
|
||||
public TagRootResource(RepositoryServiceFactory serviceFactory,
|
||||
TagCollectionToDtoMapper tagCollectionToDtoMapper,
|
||||
TagToTagDtoMapper tagToTagDtoMapper) {
|
||||
TagToTagDtoMapper tagToTagDtoMapper,
|
||||
ResourceLinks resourceLinks) {
|
||||
this.serviceFactory = serviceFactory;
|
||||
this.tagCollectionToDtoMapper = tagCollectionToDtoMapper;
|
||||
this.tagToTagDtoMapper = tagToTagDtoMapper;
|
||||
this.resourceLinks = resourceLinks;
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -89,7 +101,7 @@ public class TagRootResource {
|
||||
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
|
||||
Tags tags = getTags(repositoryService);
|
||||
if (tags != null && tags.getTags() != null) {
|
||||
return Response.ok(tagCollectionToDtoMapper.map(namespace, name, tags.getTags())).build();
|
||||
return Response.ok(tagCollectionToDtoMapper.map(tags.getTags(), repositoryService.getRepository())).build();
|
||||
} else {
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity("Error on getting tag from repository.")
|
||||
@@ -98,6 +110,66 @@ public class TagRootResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("")
|
||||
@Produces(VndMediaType.TAG_REQUEST)
|
||||
@Operation(summary = "Create tag",
|
||||
description = "Creates a new tag.",
|
||||
tags = "Repository",
|
||||
requestBody = @RequestBody(
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.TAG_REQUEST,
|
||||
schema = @Schema(implementation = TagRequestDto.class),
|
||||
examples = @ExampleObject(
|
||||
name = "Create a new tag for a revision",
|
||||
value = "{\n \"revision\":\"734713bc047d87bf7eac9674765ae793478c50d3\",\n \"name\":\"v1.1.0\"\n}",
|
||||
summary = "Create a tag"
|
||||
)
|
||||
)
|
||||
))
|
||||
@ApiResponse(
|
||||
responseCode = "201",
|
||||
description = "create success",
|
||||
headers = @Header(
|
||||
name = "Location",
|
||||
description = "uri to the created tag",
|
||||
schema = @Schema(type = "string")
|
||||
)
|
||||
)
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
|
||||
@ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "not found, no tag with the specified name available in the repository",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid TagRequestDto tagRequest) throws IOException {
|
||||
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
|
||||
String revision = tagRequest.getRevision();
|
||||
String tagName = tagRequest.getName();
|
||||
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
|
||||
if (tagExists(tagName, repositoryService)) {
|
||||
throw alreadyExists(entity(Tag.class, tagName).in(repositoryService.getRepository()));
|
||||
}
|
||||
Repository repository = repositoryService.getRepository();
|
||||
RepositoryPermissions.push(repository).check();
|
||||
TagCommandBuilder tagCommandBuilder = repositoryService.getTagCommand();
|
||||
final Tag newTag = tagCommandBuilder.create()
|
||||
.setRevision(revision)
|
||||
.setName(tagName)
|
||||
.execute();
|
||||
return Response.created(URI.create(resourceLinks.tag().self(namespace, name, newTag.getName()))).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{tagName}")
|
||||
@@ -136,7 +208,7 @@ public class TagRootResource {
|
||||
.filter(t -> tagName.equals(t.getName()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> createNotFoundException(namespace, name, tagName));
|
||||
return Response.ok(tagToTagDtoMapper.map(tag, namespaceAndName)).build();
|
||||
return Response.ok(tagToTagDtoMapper.map(tag, repositoryService.getRepository())).build();
|
||||
} else {
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity("Error on getting tag from repository.")
|
||||
@@ -145,6 +217,45 @@ public class TagRootResource {
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{tagName}")
|
||||
@Produces(VndMediaType.TAG)
|
||||
@Operation(summary = "Delete tag", description = "Deletes the tag provided in the path", tags = "Repository")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "success"
|
||||
)
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
|
||||
@ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "not found, no tag with the specified name available in the repository",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public Response delete(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("tagName") String tagName) throws IOException {
|
||||
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
|
||||
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
|
||||
RepositoryPermissions.push(repositoryService.getRepository()).check();
|
||||
|
||||
if (tagExists(tagName, repositoryService)) {
|
||||
repositoryService.getTagCommand().delete()
|
||||
.setName(tagName)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
private NotFoundException createNotFoundException(String namespace, String name, String tagName) {
|
||||
return notFound(entity("Tag", tagName).in("Repository", namespace + "/" + name));
|
||||
}
|
||||
@@ -155,5 +266,9 @@ public class TagRootResource {
|
||||
return repositoryService.getTagsCommand().getTags();
|
||||
}
|
||||
|
||||
private boolean tagExists(String tagName, RepositoryService repositoryService) throws IOException {
|
||||
return getTags(repositoryService)
|
||||
.getTagByName(tagName) != null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.Named;
|
||||
import org.mapstruct.ObjectFactory;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryPermissions;
|
||||
import sonia.scm.repository.Tag;
|
||||
import sonia.scm.web.EdisonHalAppender;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -52,17 +52,23 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper {
|
||||
|
||||
@Mapping(target = "date", source = "date", qualifiedByName = "mapDate")
|
||||
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
|
||||
public abstract TagDto map(Tag tag, @Context NamespaceAndName namespaceAndName);
|
||||
@Mapping(target = "signatures")
|
||||
public abstract TagDto map(Tag tag, @Context Repository repository);
|
||||
|
||||
@ObjectFactory
|
||||
TagDto createDto(@Context NamespaceAndName namespaceAndName, Tag tag) {
|
||||
TagDto createDto(@Context Repository repository, Tag tag) {
|
||||
Links.Builder linksBuilder = linkingTo()
|
||||
.self(resourceLinks.tag().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getName()))
|
||||
.single(link("sources", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision())))
|
||||
.single(link("changeset", resourceLinks.changeset().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision())));
|
||||
.self(resourceLinks.tag().self(repository.getNamespace(), repository.getName(), tag.getName()))
|
||||
.single(link("sources", resourceLinks.source().self(repository.getNamespace(), repository.getName(), tag.getRevision())))
|
||||
.single(link("changeset", resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), tag.getRevision())));
|
||||
|
||||
if (tag.getDeletable() && RepositoryPermissions.push(repository).isPermitted()) {
|
||||
linksBuilder
|
||||
.single(link("delete", resourceLinks.tag().delete(repository.getNamespace(), repository.getName(), tag.getName())));
|
||||
}
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), tag, namespaceAndName);
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), tag, repository);
|
||||
|
||||
return new TagDto(linksBuilder.build(), embeddedBuilder.build());
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.google.inject.util.Providers;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.shiro.authz.AuthorizationException;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import org.apache.shiro.subject.support.SubjectThreadState;
|
||||
import org.apache.shiro.util.ThreadContext;
|
||||
@@ -46,17 +46,24 @@ import sonia.scm.repository.Tag;
|
||||
import sonia.scm.repository.Tags;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.repository.api.TagCommandBuilder;
|
||||
import sonia.scm.repository.api.TagsCommandBuilder;
|
||||
import sonia.scm.web.RestDispatcher;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Slf4j
|
||||
@@ -79,6 +86,12 @@ public class TagRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
@Mock
|
||||
private TagsCommandBuilder tagsCommandBuilder;
|
||||
@Mock
|
||||
private TagCommandBuilder tagCommandBuilder;
|
||||
@Mock
|
||||
private TagCommandBuilder.TagCreateCommandBuilder tagCreateCommandBuilder;
|
||||
@Mock
|
||||
private TagCommandBuilder.TagDeleteCommandBuilder tagDeleteCommandBuilder;
|
||||
private TagCollectionToDtoMapper tagCollectionToDtoMapper;
|
||||
|
||||
@InjectMocks
|
||||
@@ -89,17 +102,21 @@ public class TagRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
|
||||
@Before
|
||||
public void prepareEnvironment() throws Exception {
|
||||
public void prepareEnvironment() {
|
||||
tagCollectionToDtoMapper = new TagCollectionToDtoMapper(resourceLinks, tagToTagDtoMapper);
|
||||
tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper);
|
||||
tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper, resourceLinks);
|
||||
dispatcher.addSingletonResource(getRepositoryRootResource());
|
||||
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService);
|
||||
when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService);
|
||||
when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo"));
|
||||
when(repositoryService.getTagsCommand()).thenReturn(tagsCommandBuilder);
|
||||
when(repositoryService.getTagCommand()).thenReturn(tagCommandBuilder);
|
||||
subjectThreadState.bind();
|
||||
ThreadContext.bind(subject);
|
||||
when(subject.isPermitted(any(String.class))).thenReturn(true);
|
||||
when(tagCreateCommandBuilder.setName(any())).thenReturn(tagCreateCommandBuilder);
|
||||
when(tagCreateCommandBuilder.setRevision(any())).thenReturn(tagCreateCommandBuilder);
|
||||
when(tagDeleteCommandBuilder.setName(any())).thenReturn(tagDeleteCommandBuilder);
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -211,4 +228,116 @@ public class TagRootResourceTest extends RepositoryTestBase {
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", tag2)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"revision\":\"%s\"", revision2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateTag() throws URISyntaxException, IOException {
|
||||
Tags tags = new Tags();
|
||||
tags.setTags(Lists.emptyList());
|
||||
when(tagsCommandBuilder.getTags()).thenReturn(tags);
|
||||
when(tagCommandBuilder.create()).thenReturn(tagCreateCommandBuilder);
|
||||
when(tagCreateCommandBuilder.execute()).thenReturn(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411"));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.post(TAG_URL)
|
||||
.content("{\"name\": \"newtag\",\"revision\":\"592d797cd36432e591416e8b2b98154f4f163411\"}".getBytes())
|
||||
.contentType(VndMediaType.TAG_REQUEST);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(201, response.getStatus());
|
||||
assertEquals(
|
||||
URI.create("/v2/repositories/space/repo/tags/newtag"),
|
||||
response.getOutputHeaders().getFirst("Location"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotCreateTagIfNotPermitted() throws IOException, URISyntaxException {
|
||||
doThrow(AuthorizationException.class).when(subject).checkPermission("repository:push:repoId");
|
||||
Tags tags = new Tags();
|
||||
tags.setTags(Lists.emptyList());
|
||||
when(tagsCommandBuilder.getTags()).thenReturn(tags);
|
||||
when(tagCommandBuilder.create()).thenReturn(tagCreateCommandBuilder);
|
||||
when(tagCreateCommandBuilder.execute()).thenReturn(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411"));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.post(TAG_URL)
|
||||
.content("{\"name\": \"newtag\",\"revision\":\"592d797cd36432e591416e8b2b98154f4f163411\"}".getBytes())
|
||||
.contentType(VndMediaType.TAG_REQUEST);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(403, response.getStatus());
|
||||
verify(tagCommandBuilder, never()).create();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldThrowExceptionIfTagAlreadyExists() throws URISyntaxException, IOException {
|
||||
Tags tags = new Tags();
|
||||
tags.setTags(Collections.singletonList(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411")));
|
||||
when(tagsCommandBuilder.getTags()).thenReturn(tags);
|
||||
when(tagCommandBuilder.create()).thenReturn(tagCreateCommandBuilder);
|
||||
when(tagCreateCommandBuilder.execute()).thenReturn(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411"));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.post(TAG_URL)
|
||||
.content("{\"name\": \"newtag\",\"revision\":\"592d797cd36432e591416e8b2b98154f4f163411\"}".getBytes())
|
||||
.contentType(VndMediaType.TAG_REQUEST);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(409, response.getStatus());
|
||||
verify(tagCommandBuilder, never()).create();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldDeleteTag() throws IOException, URISyntaxException {
|
||||
Tags tags = new Tags();
|
||||
tags.setTags(Collections.singletonList(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411")));
|
||||
when(tagsCommandBuilder.getTags()).thenReturn(tags);
|
||||
when(tagCommandBuilder.delete()).thenReturn(tagDeleteCommandBuilder);
|
||||
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.delete(TAG_URL + "newtag");
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(204, response.getStatus());
|
||||
verify(tagCommandBuilder).delete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn204EvenIfTagDoesntExist() throws IOException, URISyntaxException {
|
||||
Tags tags = new Tags();
|
||||
tags.setTags(Collections.emptyList());
|
||||
when(tagsCommandBuilder.getTags()).thenReturn(tags);
|
||||
when(tagCommandBuilder.delete()).thenReturn(tagDeleteCommandBuilder);
|
||||
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.delete(TAG_URL + "newtag");
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(204, response.getStatus());
|
||||
verify(tagCommandBuilder, never()).delete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotDeleteTagIfNotPermitted() throws IOException, URISyntaxException {
|
||||
doThrow(AuthorizationException.class).when(subject).checkPermission("repository:modify:repoId");
|
||||
Tags tags = new Tags();
|
||||
tags.setTags(Collections.singletonList(new Tag("newtag", "592d797cd36432e591416e8b2b98154f4f163411")));
|
||||
when(tagsCommandBuilder.getTags()).thenReturn(tags);
|
||||
when(tagCommandBuilder.delete()).thenReturn(tagDeleteCommandBuilder);
|
||||
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.delete(TAG_URL + "newtag");
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(403, response.getStatus());
|
||||
verify(tagCommandBuilder, never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +24,29 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import org.apache.shiro.util.ThreadContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryTestData;
|
||||
import sonia.scm.repository.Signature;
|
||||
import sonia.scm.repository.SignatureStatus;
|
||||
import sonia.scm.repository.Tag;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TagToTagDtoMapperTest {
|
||||
@@ -45,25 +57,76 @@ class TagToTagDtoMapperTest {
|
||||
@InjectMocks
|
||||
private TagToTagDtoMapperImpl mapper;
|
||||
|
||||
@Mock
|
||||
private Subject subject;
|
||||
|
||||
@BeforeEach
|
||||
void setupSubject() {
|
||||
ThreadContext.bind(subject);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ThreadContext.unbindSubject();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAppendLinks() {
|
||||
HalEnricherRegistry registry = new HalEnricherRegistry();
|
||||
registry.register(Tag.class, (ctx, appender) -> {
|
||||
NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class);
|
||||
Repository repository = ctx.oneRequireByType(Repository.class);
|
||||
Tag tag = ctx.oneRequireByType(Tag.class);
|
||||
appender.appendLink("yo", "http://" + repository.logString() + "/" + tag.getName());
|
||||
|
||||
appender.appendLink("yo", "http://" + repository.getNamespace() + "/" + repository.getName() + "/" + tag.getName());
|
||||
});
|
||||
mapper.setRegistry(registry);
|
||||
|
||||
TagDto dto = mapper.map(new Tag("1.0.0", "42"), new NamespaceAndName("hitchhiker", "hog"));
|
||||
assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/hog/1.0.0");
|
||||
TagDto dto = mapper.map(new Tag("1.0.0", "42"), RepositoryTestData.createHeartOfGold());
|
||||
assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/HeartOfGold/1.0.0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapDate() {
|
||||
final long now = Instant.now().getEpochSecond() * 1000;
|
||||
TagDto dto = mapper.map(new Tag("1.0.0", "42", now), new NamespaceAndName("hitchhiker", "hog"));
|
||||
TagDto dto = mapper.map(new Tag("1.0.0", "42", now), RepositoryTestData.createHeartOfGold());
|
||||
assertThat(dto.getDate()).isEqualTo(Instant.ofEpochMilli(now));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldContainSignatureArray() {
|
||||
TagDto dto = mapper.map(new Tag("1.0.0", "42"), RepositoryTestData.createHeartOfGold());
|
||||
assertThat(dto.getSignatures()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapSignatures() {
|
||||
final Tag tag = new Tag("1.0.0", "42");
|
||||
tag.addSignature(new Signature("29v391239v", "gpg", SignatureStatus.VERIFIED, "me", Collections.emptySet()));
|
||||
TagDto dto = mapper.map(tag, RepositoryTestData.createHeartOfGold());
|
||||
assertThat(dto.getSignatures()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAddDeleteLink() {
|
||||
Repository repository = RepositoryTestData.createHeartOfGold();
|
||||
when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(true);
|
||||
final Tag tag = new Tag("1.0.0", "42");
|
||||
TagDto dto = mapper.map(tag, repository);
|
||||
assertThat(dto.getLinks().getLinkBy("delete")).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotAddDeleteLinkIfPermissionsAreMissing() {
|
||||
final Tag tag = new Tag("1.0.0", "42");
|
||||
TagDto dto = mapper.map(tag, RepositoryTestData.createHeartOfGold());
|
||||
assertThat(dto.getLinks().getLinkBy("delete")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotAddDeleteLinksForUndeletableTags() {
|
||||
final Tag tag = new Tag("1.0.0", "42", null, false);
|
||||
TagDto dto = mapper.map(tag, RepositoryTestData.createHeartOfGold());
|
||||
assertThat(dto.getLinks().getLinkBy("delete")).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||