Merge branch 'develop' into feature/import_git_from_url

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

View File

@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Show the date of the last commit for branches in the frontend ([#1439](https://github.com/scm-manager/scm-manager/pull/1439))
- 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View File

@@ -34,6 +34,19 @@ Beispielsweise wird der Text hitchhiker/HeartOfGold@1a2b3c4 zu einem Link zu dem
![Repository-Code-Changesets](assets/repository-code-changesetDetails.png)
#### Tags
Alle Tags eines Changesets werden in der oberen rechten Ecke der Detailseite angezeigt.
![Repository-Code-Changesets](assets/repository-code-changeset-with-tag.png)
#### Tags erstellen
Neue Tags für ein Changeset können direkt in dessen Übersichtsseite erstellt werden.
Es muss lediglich ein gewünschter Name angegeben werden, welcher die gleichen Formatierungsbeschränkungen wie Branches erfüllt.
![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png)
### Datei Details
Nach einem Klick auf eine Datei in den Sources landet man in der Detailansicht der Datei. Dabei sind je nach Dateiformat unterschiedliche Ansichten zu sehen:

View File

@@ -11,3 +11,20 @@ Auf der Tags-Übersicht sind die existierenden Tags nach Erstelldatum absteigend
Hier wird ein Befehl zum Arbeiten mit dem Tag auf einer Kommandozeile aufgeführt.
![Tag Detailseite](assets/repository-tag-detailView.png)
#### Tag-Signaturen
Wenn mindestens eine Signatur für einen Tag existiert, wird der Verifizierungsstatus des Tags als Schlüsselsymbol hinter dessen Namen in der Detailansicht dargestellt.
Ein Tag kann mehrere Signaturen haben.
Abhängig vom Status der einzelnen Signaturen, wird das Symbol entsprechend eingefärbt:
- wenn mindestens eine Signatur ungültig ist, wird der Schlüssel `rot` dargestellt ANDERNFALLS
- wenn mindestens eine Signatur gültigt ist, wird der Schlüssel `grün` dargestellt ANDERNFALLS
- wird der Schlüssel `grau` dargestellt
Wird der Mauszeiger über das Symbol bewegt, erscheint eine Liste aller Signaturen des Tags.
![Tag Signatures](assets/repository-tag-signatures.png)
### Tags löschen
Tags können direkt von der Übersicht aus oder auf der Detailseite gelöscht werden.

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 211 KiB

View File

@@ -32,9 +32,20 @@ You can expand the diffs gradually or completely by clicking on the blue bars.
If commit links formatted like "namespace/name@commitId" are used in the changeset description they will be rendered to internal links.
For example the text hitchhiker/HeartOfGold@1a2b3c4 will be transformed to a link directing to the commit 1a2b3c4 of the repository hitchhiker/heartOfGold.
![Repository-Code-Changesets](assets/repository-code-changesetDetails.png)
#### Tags
All tags for a changeset are displayed in the top-right corner of the details page.
![Repository-Code-Changesets](assets/repository-code-changeset-with-tag.png)
#### Creating Tags
New tags for a changeset can be created directly on its details page.
Only a name has to be provided that meets the same formatting conditions as branches.
![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png)
### File Details
After clicking on a file in the sources, the details of the file are shown. Depending on the format of the file, there are different views:

View File

@@ -11,3 +11,20 @@ The tag overview shows the tags that exist for this repository. By clicking on a
This page shows a command to work with the tag on the command line.
![Tag Details Page](assets/repository-tag-detailView.png)
#### Tag Signatures
If there is at least one signature on the tag, the verification status is displayed as a key icon after its name on its details page.
A tag can have multiple signatures.
Depending on the status of the individual signatures, the key will have a distinct color indicator:
- if at least one signature on the tag is invalid, the key will be `red` OTHERWISE
- if at least one signature is valid, the key will be `green` OTHERWISE
- the key will be `gray`
If you hover the key icon, a list of all signatures on the tag will pop up.
![Tag Signatures](assets/repository-tag-signatures.png)
### Deleting Tags
Tags can be deleted directly on the tags overview page or on the details page of the tag.

View File

@@ -28,7 +28,10 @@ import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.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);
}
}

View File

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

View File

@@ -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.
*

View File

@@ -0,0 +1,103 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import sonia.scm.repository.Tag;
import sonia.scm.repository.spi.TagCommand;
import java.io.IOException;
/**
* @since 2.11.0
*/
public final class TagCommandBuilder {
private final TagCommand command;
public TagCommandBuilder(TagCommand command) {
this.command = command;
}
/**
* Initialize a command to tag a revision.
*
* Set parameters and call {@link TagCreateCommandBuilder#execute()}.
*
* @since 2.11.0
*/
public TagCreateCommandBuilder create() {
return new TagCreateCommandBuilder();
}
/**
* Initialize a command to delete a tag.
*
* Set parameters and call {@link TagDeleteCommandBuilder#execute()}.
*
* @since 2.11.0
*/
public TagDeleteCommandBuilder delete() {
return new TagDeleteCommandBuilder();
}
public final class TagCreateCommandBuilder {
private final TagCreateRequest request = new TagCreateRequest();
/**
* @param revision The revision identifier for which to create the tag
*/
public TagCreateCommandBuilder setRevision(String revision) {
request.setRevision(revision);
return this;
}
/**
* @param name The name of the tag
*/
public TagCreateCommandBuilder setName(String name) {
request.setName(name);
return this;
}
public Tag execute() throws IOException {
return command.create(request);
}
}
public final class TagDeleteCommandBuilder {
private final TagDeleteRequest request = new TagDeleteRequest();
/**
* @param name The name of the tag that should be deleted
*/
public TagDeleteCommandBuilder setName(String name) {
request.setName(name);
return this;
}
public void execute() throws IOException {
command.delete(request);
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TagCreateRequest {
private String revision;
private String name;
}

View File

@@ -0,0 +1,36 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TagDeleteRequest {
private String name;
}

View File

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

View File

@@ -0,0 +1,39 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import sonia.scm.repository.Tag;
import sonia.scm.repository.api.TagDeleteRequest;
import sonia.scm.repository.api.TagCreateRequest;
import java.io.IOException;
/**
* @since 2.11
*/
public interface TagCommand {
Tag create(TagCreateRequest request) throws IOException;
void delete(TagDeleteRequest request) throws IOException;
}

View File

@@ -51,6 +51,7 @@ public class VndMediaType {
public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX;
public static final String 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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,214 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import com.google.common.base.Strings;
import org.apache.shiro.SecurityUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevWalk;
import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.PostReceiveRepositoryHookEvent;
import sonia.scm.repository.PreReceiveRepositoryHookEvent;
import sonia.scm.repository.RepositoryHookEvent;
import sonia.scm.repository.RepositoryHookType;
import sonia.scm.repository.Tag;
import sonia.scm.repository.api.HookContext;
import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.HookFeature;
import sonia.scm.repository.api.HookTagProvider;
import sonia.scm.repository.api.TagCreateRequest;
import sonia.scm.repository.api.TagDeleteRequest;
import sonia.scm.user.User;
import javax.inject.Inject;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static java.util.Collections.emptyList;
import static java.util.Collections.singleton;
import static java.util.Collections.singletonList;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
public class GitTagCommand extends AbstractGitCommand implements TagCommand {
private final HookContextFactory hookContextFactory;
private final ScmEventBus eventBus;
@Inject
GitTagCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus) {
super(context);
this.hookContextFactory = hookContextFactory;
this.eventBus = eventBus;
}
@Override
public Tag create(TagCreateRequest request) {
final String name = request.getName();
final String revision = request.getRevision();
if (Strings.isNullOrEmpty(revision)) {
throw new IllegalArgumentException("Revision is required");
}
if (Strings.isNullOrEmpty(name)) {
throw new IllegalArgumentException("Name is required");
}
try (Git git = new Git(context.open())) {
RevObject revObject;
Long tagTime;
ObjectId taggedCommitObjectId = git.getRepository().resolve(revision);
if (taggedCommitObjectId == null) {
throw notFound(entity("revision", revision).in(repository));
}
try (RevWalk walk = new RevWalk(git.getRepository())) {
revObject = walk.parseAny(taggedCommitObjectId);
tagTime = GitUtil.getTagTime(walk, taggedCommitObjectId);
}
Tag tag = new Tag(name, revision, tagTime);
RepositoryHookEvent hookEvent = createTagHookEvent(TagHookContextProvider.createHookEvent(tag));
eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent));
User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class);
PersonIdent taggerIdent = new PersonIdent(user.getDisplayName(), user.getMail());
git.tag()
.setObjectId(revObject)
.setTagger(taggerIdent)
.setName(name)
.call();
eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent));
return tag;
} catch (IOException | GitAPIException ex) {
throw new InternalRepositoryException(repository, "could not create tag " + name + " for revision " + revision, ex);
}
}
@Override
public void delete(TagDeleteRequest request) {
String name = request.getName();
if (Strings.isNullOrEmpty(name)) {
throw new IllegalArgumentException("Name is required");
}
try (Git git = new Git(context.open())) {
final Repository repository = git.getRepository();
Optional<Ref> tagRef = findTagRef(git, name);
Tag tag;
// Deleting a non-existent tag is a valid action and simply succeeds without
// anything happening.
if (!tagRef.isPresent()) {
return;
}
try (RevWalk walk = new RevWalk(repository)) {
final RevCommit commit = GitUtil.getCommit(repository, walk, tagRef.get());
Long tagTime = GitUtil.getTagTime(walk, tagRef.get().getObjectId());
tag = new Tag(name, commit.name(), tagTime);
}
RepositoryHookEvent hookEvent = createTagHookEvent(TagHookContextProvider.deleteHookEvent(tag));
eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent));
git.tagDelete().setTags(name).call();
eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent));
} catch (GitAPIException | IOException e) {
throw new InternalRepositoryException(repository, "could not delete tag " + name, e);
}
}
private Optional<Ref> findTagRef(Git git, String name) throws GitAPIException {
final String tagRef = "refs/tags/" + name;
return git.tagList().call().stream().filter(it -> it.getName().equals(tagRef)).findAny();
}
private RepositoryHookEvent createTagHookEvent(TagHookContextProvider hookEvent) {
HookContext context = hookContextFactory.createContext(hookEvent, this.context.getRepository());
return new RepositoryHookEvent(context, this.context.getRepository(), RepositoryHookType.PRE_RECEIVE);
}
private static class TagHookContextProvider extends HookContextProvider {
private final List<Tag> newTags;
private final List<Tag> deletedTags;
private TagHookContextProvider(List<Tag> newTags, List<Tag> deletedTags) {
this.newTags = newTags;
this.deletedTags = deletedTags;
}
static TagHookContextProvider createHookEvent(Tag newTag) {
return new TagHookContextProvider(singletonList(newTag), emptyList());
}
static TagHookContextProvider deleteHookEvent(Tag deletedTag) {
return new TagHookContextProvider(emptyList(), singletonList(deletedTag));
}
@Override
public Set<HookFeature> getSupportedFeatures() {
return singleton(HookFeature.TAG_PROVIDER);
}
@Override
public HookTagProvider getTagProvider() {
return new HookTagProvider() {
@Override
public List<Tag> getCreatedTags() {
return newTags;
}
@Override
public List<Tag> getDeletedTags() {
return deletedTags;
}
};
}
@Override
public HookChangesetProvider getChangesetProvider() {
return r -> new HookChangesetResponse(emptyList());
}
}
}

View File

@@ -30,15 +30,21 @@ import com.google.common.base.Function;
import com.google.common.collect.Lists;
import 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;
}
}

View File

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

View File

@@ -0,0 +1,170 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.eclipse.jgit.lib.GpgSigner;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.GitTestHelper;
import sonia.scm.repository.PostReceiveRepositoryHookEvent;
import sonia.scm.repository.PreReceiveRepositoryHookEvent;
import sonia.scm.repository.Tag;
import sonia.scm.repository.api.HookContext;
import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.TagDeleteRequest;
import sonia.scm.repository.api.TagCreateRequest;
import sonia.scm.security.GPG;
import sonia.scm.util.MockUtil;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class GitTagCommandTest extends AbstractGitCommandTestBase {
@Mock
private GPG gpg;
@Mock
private HookContextFactory hookContextFactory;
@Mock
private ScmEventBus eventBus;
private Subject subject;
@Before
public void setSigner() {
GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner());
}
@Before
public void bindThreadContext() {
SecurityUtils.setSecurityManager(new DefaultSecurityManager());
subject = MockUtil.createUserSubject(SecurityUtils.getSecurityManager());
ThreadContext.bind(subject);
}
@After
public void unbindThreadContext() {
ThreadContext.unbindSubject();
ThreadContext.unbindSecurityManager();
}
@Test
public void shouldCreateATag() throws IOException {
createCommand().create(new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "newtag"));
Optional<Tag> optionalTag = findTag(createContext(), "newtag");
assertThat(optionalTag).isNotEmpty();
final Tag tag = optionalTag.get();
assertThat(tag.getName()).isEqualTo("newtag");
assertThat(tag.getRevision()).isEqualTo("592d797cd36432e591416e8b2b98154f4f163411");
}
@Test
public void shouldPostCreateEvent() {
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
doNothing().when(eventBus).post(captor.capture());
when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext);
createCommand().create(new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "newtag"));
List<Object> events = captor.getAllValues();
assertThat(events.get(0)).isInstanceOf(PreReceiveRepositoryHookEvent.class);
assertThat(events.get(1)).isInstanceOf(PostReceiveRepositoryHookEvent.class);
PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0);
assertThat(event.getContext().getTagProvider().getCreatedTags().get(0).getName()).isEqualTo("newtag");
assertThat(event.getContext().getTagProvider().getDeletedTags()).isEmpty();
}
@Test
public void shouldDeleteATag() throws IOException {
final GitContext context = createContext();
Optional<Tag> tag = findTag(context, "test-tag");
assertThat(tag).isNotEmpty();
createCommand().delete(new TagDeleteRequest("test-tag"));
tag = findTag(context, "test-tag");
assertThat(tag).isEmpty();
}
@Test
public void shouldPostDeleteEvent() {
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
doNothing().when(eventBus).post(captor.capture());
when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext);
createCommand().delete(new TagDeleteRequest("test-tag"));
List<Object> events = captor.getAllValues();
assertThat(events.get(0)).isInstanceOf(PreReceiveRepositoryHookEvent.class);
assertThat(events.get(1)).isInstanceOf(PostReceiveRepositoryHookEvent.class);
PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0);
assertThat(event.getContext().getTagProvider().getCreatedTags()).isEmpty();
final Tag deletedTag = event.getContext().getTagProvider().getDeletedTags().get(0);
assertThat(deletedTag.getName()).isEqualTo("test-tag");
assertThat(deletedTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1");
}
private GitTagCommand createCommand() {
return new GitTagCommand(createContext(), hookContextFactory, eventBus);
}
private List<Tag> readTags(GitContext context) throws IOException {
return new GitTagsCommand(context, gpg).getTags();
}
private Optional<Tag> findTag(GitContext context, String name) throws IOException {
List<Tag> tags = readTags(context);
return tags.stream().filter(t -> name.equals(t.getName())).findFirst();
}
private HookContext createMockedContext(InvocationOnMock invocation) {
HookContext mock = mock(HookContext.class);
when(mock.getTagProvider()).thenReturn(((HookContextProvider) invocation.getArgument(0)).getTagProvider());
return mock;
}
}

View File

@@ -24,48 +24,86 @@
package sonia.scm.repository.spi;
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";

View File

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

View File

@@ -0,0 +1,64 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import com.aragost.javahg.Changeset;
import com.aragost.javahg.Repository;
import com.aragost.javahg.commands.ExecutionException;
import com.aragost.javahg.commands.PullCommand;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.work.WorkingCopy;
import java.io.IOException;
import java.util.List;
import java.util.regex.Pattern;
public class AbstractWorkingCopyCommand extends AbstractCommand {
static final Pattern HG_MESSAGE_PATTERN = Pattern.compile(".*\\[SCM\\](?: Error:)? (.*)");
protected final HgWorkingCopyFactory workingCopyFactory;
public AbstractWorkingCopyCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory)
{
super(context);
this.workingCopyFactory = workingCopyFactory;
}
protected List<Changeset> pullChangesIntoCentralRepository(WorkingCopy<Repository, Repository> workingCopy, String branch) {
try {
com.aragost.javahg.commands.PullCommand pullCommand = PullCommand.on(workingCopy.getCentralRepository());
workingCopyFactory.configure(pullCommand);
return pullCommand.execute(workingCopy.getDirectory().getAbsolutePath());
} catch (ExecutionException e) {
throw IntegrateChangesFromWorkdirException
.withPattern(HG_MESSAGE_PATTERN)
.forMessage(context.getScmRepository(), e.getMessage());
} catch (IOException e) {
throw new InternalRepositoryException(getRepository(),
String.format("Could not pull changes '%s' into central repository", branch),
e);
}
}
}

View File

@@ -26,7 +26,6 @@ package sonia.scm.repository.spi;
import com.aragost.javahg.Changeset;
import com.aragost.javahg.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);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import com.aragost.javahg.Repository;
import com.google.common.base.Strings;
import org.apache.shiro.SecurityUtils;
import sonia.scm.repository.Tag;
import sonia.scm.repository.api.TagCreateRequest;
import sonia.scm.repository.api.TagDeleteRequest;
import sonia.scm.repository.work.WorkingCopy;
import sonia.scm.user.User;
import static sonia.scm.repository.spi.UserFormatter.getUserStringFor;
public class HgTagCommand extends AbstractWorkingCopyCommand implements TagCommand {
public static final String DEFAULT_BRANCH_NAME = "default";
public HgTagCommand(HgCommandContext context, HgWorkingCopyFactory workingCopyFactory) {
super(context, workingCopyFactory);
}
@Override
public Tag create(TagCreateRequest request) {
try (WorkingCopy<Repository, Repository> workingCopy = workingCopyFactory.createWorkingCopy(getContext(), DEFAULT_BRANCH_NAME)) {
Repository repository = getContext().open();
String rev = request.getRevision();
if (Strings.isNullOrEmpty(rev)) {
rev = repository.tip().getNode();
}
com.aragost.javahg.commands.TagCommand.on(workingCopy.getWorkingRepository())
.rev(rev)
.user(getUserStringFor(SecurityUtils.getSubject().getPrincipals().oneByType(User.class)))
.execute(request.getName());
pullChangesIntoCentralRepository(workingCopy, DEFAULT_BRANCH_NAME);
return new Tag(request.getName(), rev);
}
}
@Override
public void delete(TagDeleteRequest request) {
try (WorkingCopy<Repository, Repository> workingCopy = workingCopyFactory.createWorkingCopy(getContext(), DEFAULT_BRANCH_NAME)) {
com.aragost.javahg.commands.TagCommand.on(workingCopy.getWorkingRepository())
.user(getUserStringFor(SecurityUtils.getSubject().getPrincipals().oneByType(User.class)))
.remove()
.execute(request.getName());
pullChangesIntoCentralRepository(workingCopy, DEFAULT_BRANCH_NAME);
}
}
}

View File

@@ -39,6 +39,8 @@ import java.util.List;
*/
public class HgTagsCommand extends AbstractCommand implements TagsCommand {
public 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;

View File

@@ -0,0 +1,41 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import sonia.scm.repository.Person;
import sonia.scm.user.User;
final class UserFormatter {
private UserFormatter() {
}
static String getUserStringFor(User user) {
return getUserStringFor(new Person(user.getName(), user.getMail()));
}
static String getUserStringFor(Person person) {
return String.format("%s <%s>", person.getName(), person.getMail());
}
}

View File

@@ -184,14 +184,14 @@ public class HgModifyCommandTest extends AbstractHgCommandTestBase {
@Test
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");
}

View File

@@ -0,0 +1,72 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import com.aragost.javahg.commands.PullCommand;
import com.google.inject.util.Providers;
import org.junit.Before;
import org.junit.Test;
import sonia.scm.repository.HgTestUtil;
import sonia.scm.repository.Tag;
import sonia.scm.repository.api.TagCreateRequest;
import sonia.scm.repository.api.TagDeleteRequest;
import sonia.scm.repository.work.NoneCachingWorkingCopyPool;
import sonia.scm.repository.work.WorkdirProvider;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
public class HgTagCommandTest extends AbstractHgCommandTestBase {
private SimpleHgWorkingCopyFactory workingCopyFactory;
@Before
public void initWorkingCopyFactory() {
workingCopyFactory = new SimpleHgWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider())) {
@Override
public void configure(PullCommand pullCommand) {
// we do not want to configure http hooks in this unit test
}
};
}
@Test
public void shouldCreateAndDeleteTagCorrectly() {
// Create
new HgTagCommand(cmdContext, workingCopyFactory).create(new TagCreateRequest("79b6baf49711", "newtag"));
List<Tag> tags = new HgTagsCommand(cmdContext).getTags();
assertThat(tags).hasSize(2);
final Tag newTag = tags.get(1);
assertThat(newTag.getName()).isEqualTo("newtag");
// Delete
new HgTagCommand(cmdContext, workingCopyFactory).delete(new TagDeleteRequest("newtag"));
tags = new HgTagsCommand(cmdContext).getTags();
assertThat(tags).hasSize(1);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Modal, InputField, Button, apiClient } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
import { Tag } from "@scm-manager/ui-types";
import { isBranchValid } from "../validation";
type Props = WithTranslation & {
existingTagsLink: string;
tagCreationLink: string;
onClose: () => void;
onCreated: () => void;
onError: (error: Error) => void;
revision: string;
};
const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLink, onCreated, onError, revision }) => {
const [newTagName, setNewTagName] = useState("");
const [loading, setLoading] = useState(false);
const [tagNames, setTagNames] = useState<string[]>([]);
useEffect(() => {
apiClient
.get(existingTagsLink)
.then(response => response.json())
.then(json => setTagNames(json._embedded.tags.map((tag: Tag) => tag.name)));
}, [existingTagsLink]);
const createTag = () => {
setLoading(true);
apiClient
.post(tagCreationLink, {
revision,
name: newTagName
})
.then(onCreated)
.catch(onError)
.finally(() => setLoading(false));
};
let validationError = "";
if (newTagName !== "") {
if (tagNames.includes(newTagName)) {
validationError = "tags.create.form.field.name.error.exists";
} else if (!isBranchValid(newTagName)) {
validationError = "tags.create.form.field.name.error.format";
}
}
return (
<Modal
title={t("tags.create.title")}
active={true}
body={
<>
<InputField
name="name"
label={t("tags.create.form.field.name.label")}
onChange={val => setNewTagName(val)}
value={newTagName}
validationError={!!validationError}
errorMessage={t(validationError)}
/>
<div className="mt-6">{t("tags.create.hint")}</div>
</>
}
footer={
<>
<Button action={onClose}>{t("tags.create.cancel")}</Button>
<Button
color="success"
action={() => createTag()}
loading={loading}
disabled={!!validationError || newTagName.length === 0}
>
{t("tags.create.confirm")}
</Button>
</>
}
closeFunction={onClose}
/>
);
};
export default withTranslation("repos")(CreateTagModal);

View File

@@ -22,10 +22,11 @@
* SOFTWARE.
*/
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;

View File

@@ -0,0 +1,37 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Person } from "./Person";
import { Link } from "./hal";
export type Signature = {
keyId: string;
type: string;
status: "VERIFIED" | "NOT_FOUND" | "INVALID";
owner?: string;
contacts?: Person[];
_links?: {
rawKey?: Link;
};
};

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { apiClient, ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { Link, Repository, Tag } from "@scm-manager/ui-types";
type Props = {
repository: Repository;
tag: Tag;
};
const DeleteTag: FC<Props> = ({ tag, repository }) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [t] = useTranslation("repos");
const history = useHistory();
const deleteBranch = () => {
apiClient
.delete((tag._links.delete as Link).href)
.then(() => history.push(`/repo/${repository.namespace}/${repository.name}/tags/`))
.catch(setError);
};
if (!tag._links.delete) {
return null;
}
let confirmAlert = null;
if (showConfirmAlert) {
confirmAlert = (
<ConfirmAlert
title={t("tag.delete.confirmAlert.title")}
message={t("tag.delete.confirmAlert.message", { tag: tag.name })}
buttons={[
{
className: "is-outlined",
label: t("tag.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
},
{
label: t("tag.delete.confirmAlert.cancel"),
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
);
}
return (
<>
<ErrorNotification error={error} />
{showConfirmAlert && confirmAlert}
<Level
left={
<p>
<strong>{t("tag.delete.subtitle")}</strong>
<br />
{t("tag.delete.description")}
</p>
}
right={<DeleteButton label={t("tag.delete.button")} action={() => setShowConfirmAlert(true)} />}
/>
</>
);
};
export default DeleteTag;

View File

@@ -0,0 +1,59 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Repository, Tag } from "@scm-manager/ui-types";
import { Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { DangerZoneContainer } from "../../containers/RepositoryDangerZone";
import DeleteTag from "./DeleteTag";
type Props = {
repository: Repository;
tag: Tag;
};
const TagDangerZone: FC<Props> = ({ repository, tag }) => {
const [t] = useTranslation("repos");
const dangerZone = [];
if (tag?._links?.delete) {
dangerZone.push(<DeleteTag repository={repository} tag={tag} key={dangerZone.length} />);
}
if (dangerZone.length === 0) {
return null;
}
return (
<>
<hr />
<Subtitle subtitle={t("tag.dangerZone")} />
<DangerZoneContainer>{dangerZone}</DangerZoneContainer>
</>
);
};
export default TagDangerZone;

View File

@@ -40,7 +40,7 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
const [error, setError] = useState<Error | undefined>(undefined);
const [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>;
};

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -0,0 +1,49 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES;
import static sonia.scm.repository.Tag.VALID_REV;
@Getter
@Setter
public class TagRequestDto {
@Pattern(regexp = VALID_REV)
@NotEmpty
@Length(min = 1, max = 100)
private String revision;
@Pattern(regexp = VALID_BRANCH_NAMES)
@NotEmpty
@Length(min = 1, max = 100)
private String name;
}

View File

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

View File

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

View File

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

View File

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