mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-10-26 00:56:09 +02:00
Introduce Git Revert functionality to SCM-Manager
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
docs/de/user/repo/assets/repository-code-changeset-revert.png
Normal file
BIN
docs/de/user/repo/assets/repository-code-changeset-revert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
@@ -84,6 +84,24 @@ Es muss lediglich ein gewünschter Name angegeben werden, welcher die gleichen F
|
||||
|
||||

|
||||
|
||||
#### Reverts
|
||||
In Changesets innerhalb von Git-Repositories steht in der oberen rechten Ecke (unter "Tag erstellen") ein Knopf zum Reverten des Commits.
|
||||
|
||||
**Hinweis:** Der Revert-Knopf wird nur dann angezeigt, wenn der Commit genau einen Vorgänger hat.
|
||||
Commits mit mehr als einem Vorgänger (z.B. Merge-Commits) und initiale Commits ohne Vorgänger können nicht zurückgesetzt werden.
|
||||
|
||||

|
||||
|
||||
Für einen Revert ist nach Drücken des Knopfs ein Branch auszuwählen, auf welchem der Revert angewendet wird.
|
||||
Gelangt man aus der Commit-Übersicht eines Branches in den Commit, ist die Auswahl automatisch vorgenommen.
|
||||
|
||||
Ebenso kann eine Commit-Nachricht für den Revert angegeben werden.
|
||||
Sie ist automatisch ausgefüllt; es empfiehlt sich jedoch aus Gründen der Übersichtlichkeit, in dieser den Revert zu begründen.
|
||||
|
||||
Mit Drücken von "Revert" wird man auf den neu erstellten Revert-Commit automatisch weitergeleitet, sofern kein Fehler auftritt.
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/en/user/repo/assets/repository-code-changeset-revert.png
Normal file
BIN
docs/en/user/repo/assets/repository-code-changeset-revert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -85,6 +85,24 @@ Only a name has to be provided that meets the same formatting conditions as bran
|
||||
|
||||

|
||||
|
||||
#### Reverts
|
||||
Changesets within Git repositories provide a "Revert" button at the upper right-hand corner (beneath "Create Tag").
|
||||
|
||||
**Note:** The revert button is only displayed on commits with exactly one parent element.
|
||||
Commits with multiple predecessors (e.g. merge commits) and initial commits without parent cannot be reverted.
|
||||
|
||||

|
||||
|
||||
After pressing the button and before reverting a changeset, you need to first select the branch where it is applied upon.
|
||||
This selection is already filled out if you reached the changeset by the changeset overview of a specific branch.
|
||||
|
||||
Furthermore, you may type a commit message for the revert.
|
||||
It is filled out with a default message; however, it is recommended to choose a reasonable custom message in order to keep the changeset history comprehensible.
|
||||
|
||||
By pressing "Revert", you're going to be forwarded to the newly created commit including the revert if no error occurs.
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||
|
||||
2
gradle/changelog/git_revert.yaml
Normal file
2
gradle/changelog/git_revert.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Git revert commit functionality
|
||||
46
scm-core/src/main/java/sonia/scm/ConflictException.java
Normal file
46
scm-core/src/main/java/sonia/scm/ConflictException.java
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm;
|
||||
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
|
||||
public class ConflictException extends ExceptionWithContext {
|
||||
private static final String CODE = "7XUd94Iwo1";
|
||||
|
||||
public ConflictException(NamespaceAndName namespaceAndName, Collection<String> conflictingFiles) {
|
||||
super(
|
||||
createContext(namespaceAndName, conflictingFiles),
|
||||
"conflict"
|
||||
);
|
||||
}
|
||||
|
||||
private static List<ContextEntry> createContext(NamespaceAndName namespaceAndName, Collection<String> conflictingFiles) {
|
||||
return entity("files", String.join(", ", conflictingFiles))
|
||||
.in(namespaceAndName)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCode() {
|
||||
return CODE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository;
|
||||
|
||||
import sonia.scm.BadRequestException;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
|
||||
public class MultipleParentsNotAllowedException extends BadRequestException {
|
||||
public MultipleParentsNotAllowedException(String changeset) {
|
||||
super(
|
||||
Collections.emptyList(),
|
||||
String.format("%s has more than one parent changeset, which is not allowed with this request.", changeset));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCode() {
|
||||
return "3a47Hzu1e3";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository;
|
||||
|
||||
import lombok.Getter;
|
||||
import sonia.scm.BadRequestException;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
|
||||
/**
|
||||
* Thrown when a changeset has no parent.
|
||||
* @since 3.8
|
||||
*/
|
||||
@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
|
||||
@Getter
|
||||
public class NoParentException extends BadRequestException {
|
||||
|
||||
public NoParentException(String changeset) {
|
||||
super(emptyList(), String.format("%s has no parent.", changeset));
|
||||
this.revision = changeset;
|
||||
}
|
||||
|
||||
private final String revision;
|
||||
|
||||
@Override
|
||||
public String getCode() {
|
||||
return "a37jI66dup";
|
||||
}
|
||||
}
|
||||
@@ -82,5 +82,10 @@ public enum Command
|
||||
/**
|
||||
* @since 2.39.0
|
||||
*/
|
||||
CHANGESETS
|
||||
CHANGESETS,
|
||||
|
||||
/**
|
||||
* @since 3.8
|
||||
*/
|
||||
REVERT
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package sonia.scm.repository.api;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.Getter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.cache.CacheManager;
|
||||
@@ -55,22 +56,6 @@ import java.util.stream.Stream;
|
||||
* after work is finished. For closing the connection to the repository use the
|
||||
* {@link #close()} method.
|
||||
*
|
||||
* @apiviz.uses sonia.scm.repository.Feature
|
||||
* @apiviz.uses sonia.scm.repository.api.Command
|
||||
* @apiviz.uses sonia.scm.repository.api.BlameCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.BrowseCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.CatCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.DiffCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.LogCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.TagsCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.BranchesCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.IncomingCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.OutgoingCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.PullCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.PushCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.BundleCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.UnbundleCommandBuilder
|
||||
* @apiviz.uses sonia.scm.repository.api.MergeCommandBuilder
|
||||
* @since 1.17
|
||||
*/
|
||||
public final class RepositoryService implements Closeable {
|
||||
@@ -80,7 +65,10 @@ public final class RepositoryService implements Closeable {
|
||||
private final CacheManager cacheManager;
|
||||
private final PreProcessorUtil preProcessorUtil;
|
||||
private final RepositoryServiceProvider provider;
|
||||
|
||||
@Getter
|
||||
private final Repository repository;
|
||||
|
||||
@SuppressWarnings({"rawtypes", "java:S3740"})
|
||||
private final Set<ScmProtocolProvider> protocolProviders;
|
||||
private final WorkdirProvider workdirProvider;
|
||||
@@ -119,7 +107,7 @@ public final class RepositoryService implements Closeable {
|
||||
|
||||
/**
|
||||
* Closes the connection to the repository and releases all locks
|
||||
* and resources. This method should be called in a finally block e.g.:
|
||||
* and resources. This method should be called in a finally block; e.g.:
|
||||
*
|
||||
* <pre><code>
|
||||
* RepositoryService service = null;
|
||||
@@ -143,7 +131,29 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The blame command shows changeset information by line for a given file.
|
||||
* Returns true if the command is supported by the repository service.
|
||||
*
|
||||
* @param command command
|
||||
* @return true if the command is supported
|
||||
*/
|
||||
public boolean isSupported(Command command) {
|
||||
return provider.getSupportedCommands().contains(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the feature is supported by the repository service.
|
||||
*
|
||||
* @param feature feature
|
||||
* @return true if the feature is supported
|
||||
* @since 1.25
|
||||
*/
|
||||
public boolean isSupported(Feature feature) {
|
||||
return provider.getSupportedFeatures().contains(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link BlameCommandBuilder}. It can take the respective parameters and be executed to show
|
||||
* changeset information by line for a given file.
|
||||
*
|
||||
* @return instance of {@link BlameCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -157,21 +167,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The branches command list all repository branches.
|
||||
*
|
||||
* @return instance of {@link BranchesCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
*/
|
||||
public BranchesCommandBuilder getBranchesCommand() {
|
||||
LOG.debug("create branches command for repository {}", repository);
|
||||
|
||||
return new BranchesCommandBuilder(cacheManager,
|
||||
provider.getBranchesCommand(), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch command creates new branches.
|
||||
* Creates a {@link BranchCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* create new branches, if supported by the particular SCM system.
|
||||
*
|
||||
* @return instance of {@link BranchCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -186,7 +183,37 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The browse command allows browsing of a repository.
|
||||
* Creates a {@link BranchDetailsCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* get details for a branch.
|
||||
*
|
||||
* @return instance of {@link BranchDetailsCommand}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.28.0
|
||||
*/
|
||||
public BranchDetailsCommandBuilder getBranchDetailsCommand() {
|
||||
LOG.debug("create branch details command for repository {}", repository);
|
||||
return new BranchDetailsCommandBuilder(repository, provider.getBranchDetailsCommand(), cacheManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link BranchesCommandBuilder}. It can take the respective parameters and be executed to list
|
||||
* all repository branches.
|
||||
*
|
||||
* @return instance of {@link BranchesCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
*/
|
||||
public BranchesCommandBuilder getBranchesCommand() {
|
||||
LOG.debug("create branches command for repository {}", repository);
|
||||
|
||||
return new BranchesCommandBuilder(cacheManager,
|
||||
provider.getBranchesCommand(), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link BrowseCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* browse for content within a repository.
|
||||
*
|
||||
* @return instance of {@link BrowseCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -200,7 +227,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundle command creates an archive from the repository.
|
||||
* Creates a {@link BundleCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* create an archive from the repository.
|
||||
*
|
||||
* @return instance of {@link BundleCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -214,7 +242,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The cat command show the content of a given file.
|
||||
* Creates a {@link CatCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show the content of a given file.
|
||||
*
|
||||
* @return instance of {@link CatCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -227,8 +256,21 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff command shows differences between revisions for a specified file
|
||||
* or the entire revision.
|
||||
* Creates a {@link ChangesetsCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* retrieve a set of at least one changeset.
|
||||
*
|
||||
* @return Instance of {@link ChangesetsCommandBuilder}.
|
||||
* @throws CommandNotSupportedException if the command is not supported by
|
||||
* the implementation of the {@link RepositoryServiceProvider}.
|
||||
*/
|
||||
public ChangesetsCommandBuilder getChangesetsCommand() {
|
||||
LOG.debug("create changesets command for repository {}", repository);
|
||||
return new ChangesetsCommandBuilder(repository, provider.getChangesetsCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link DiffCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show differences between revisions for a specified file or the entire revision.
|
||||
*
|
||||
* @return instance of {@link DiffCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -241,8 +283,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff command shows differences between revisions for a specified file
|
||||
* or the entire revision.
|
||||
* Creates a {@link DiffResultCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show differences between revisions for a specified file or the entire revision.
|
||||
*
|
||||
* @return instance of {@link DiffResultCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -255,8 +297,36 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The incoming command shows new {@link Changeset}s found in a different
|
||||
* repository location.
|
||||
* Creates a {@link FullHealthCheckCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* inspect a repository profoundly. This might take a while in contrast to the lighter checks executed at startup.
|
||||
*
|
||||
* @return instance of {@link FullHealthCheckCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.17.0
|
||||
*/
|
||||
public FullHealthCheckCommandBuilder getFullCheckCommand() {
|
||||
LOG.debug("create full check command for repository {}", repository);
|
||||
return new FullHealthCheckCommandBuilder(provider.getFullHealthCheckCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link FileLockCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* lock and unlock files.
|
||||
*
|
||||
* @return instance of {@link FileLockCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.26.0
|
||||
*/
|
||||
public FileLockCommandBuilder getLockCommand() {
|
||||
LOG.debug("create lock command for repository {}", repository);
|
||||
return new FileLockCommandBuilder(provider.getFileLockCommand(), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link IncomingCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show new {@link Changeset}s found in a different repository location.
|
||||
*
|
||||
* @return instance of {@link IncomingCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -271,7 +341,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The log command shows revision history of entire repository or files.
|
||||
* Creates a {@link LogCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show revision history of entire repository or files.
|
||||
*
|
||||
* @return instance of {@link LogCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -285,7 +356,54 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The modification command shows file modifications in a revision.
|
||||
* Creates a {@link LookupCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* conduct a lookup which returns additional information for the repository.
|
||||
*
|
||||
* @return instance of {@link LookupCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.10.0
|
||||
*/
|
||||
public LookupCommandBuilder getLookupCommand() {
|
||||
LOG.debug("create lookup command for repository {}", repository);
|
||||
return new LookupCommandBuilder(provider.getLookupCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link MergeCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* conduct a merge of two branches.
|
||||
*
|
||||
* @return instance of {@link MergeCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public MergeCommandBuilder getMergeCommand() {
|
||||
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
|
||||
LOG.debug("create merge command for repository {}", repository);
|
||||
|
||||
return new MergeCommandBuilder(provider.getMergeCommand(), eMail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link MirrorCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* create a 'mirror' of an existing repository (specified by a URL) by copying all data
|
||||
* to the repository of this service. Therefore, this repository has to be empty (otherwise the behaviour is
|
||||
* not specified).
|
||||
*
|
||||
* @return instance of {@link MirrorCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.19.0
|
||||
*/
|
||||
public MirrorCommandBuilder getMirrorCommand() {
|
||||
LOG.debug("create mirror command for repository {}", repository);
|
||||
return new MirrorCommandBuilder(provider.getMirrorCommand(), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ModificationsCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show file modifications in a revision.
|
||||
*
|
||||
* @return instance of {@link ModificationsCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -297,7 +415,25 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The outgoing command show {@link Changeset}s not found in a remote repository.
|
||||
* Creates a {@link ModifyCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* makes changes to the files within a changeset.
|
||||
*
|
||||
* @return instance of {@link ModifyCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @see ModifyCommandBuilder
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public ModifyCommandBuilder getModifyCommand() {
|
||||
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
|
||||
LOG.debug("create modify command for repository {}", repository);
|
||||
|
||||
return new ModifyCommandBuilder(provider.getModifyCommand(), workdirProvider, repository.getId(), eMail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an {@link OutgoingCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* show {@link Changeset}s not found in a remote repository.
|
||||
*
|
||||
* @return instance of {@link OutgoingCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -312,7 +448,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pull command pull changes from a other repository.
|
||||
* Creates a {@link PullCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* pull changes from another repository.
|
||||
*
|
||||
* @return instance of {@link PullCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -327,7 +464,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The push command pushes changes to a other repository.
|
||||
* Creates a {@link PushCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* push changes to another repository.
|
||||
*
|
||||
* @return instance of {@link PushCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -341,12 +479,18 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the repository of this service.
|
||||
* Creates a {@link RevertCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* apply a revert of a chosen changeset onto the given repository/branch combination.
|
||||
*
|
||||
* @return repository of this service
|
||||
* @return Instance of {@link RevertCommandBuilder}.
|
||||
* @throws CommandNotSupportedException if the command is not supported by
|
||||
* the implementation of the {@link RepositoryServiceProvider}.
|
||||
* @since 3.8
|
||||
* @see RevertCommandBuilder
|
||||
*/
|
||||
public Repository getRepository() {
|
||||
return repository;
|
||||
public RevertCommandBuilder getRevertCommand() {
|
||||
LOG.debug("create revert command for repository {}", repository);
|
||||
return new RevertCommandBuilder(provider.getRevertCommand(), eMail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,7 +520,8 @@ public final class RepositoryService implements Closeable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The unbundle command restores a repository from the given bundle.
|
||||
* Creates an {@link UnbundleCommandBuilder}. It can take the respective parameters and be executed to
|
||||
* restore a repository from the given bundle.
|
||||
*
|
||||
* @return instance of {@link UnbundleCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
@@ -390,137 +535,6 @@ public final class RepositoryService implements Closeable {
|
||||
repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* The merge command executes a merge of two branches. It is possible to do a dry run to check, whether the given
|
||||
* branches can be merged without conflicts.
|
||||
*
|
||||
* @return instance of {@link MergeCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public MergeCommandBuilder getMergeCommand() {
|
||||
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
|
||||
LOG.debug("create merge command for repository {}", repository);
|
||||
|
||||
return new MergeCommandBuilder(provider.getMergeCommand(), eMail);
|
||||
}
|
||||
|
||||
/**
|
||||
* The modify command makes changes to the head of a branch. It is possible to
|
||||
* <ul>
|
||||
* <li>create new files</li>
|
||||
* <li>delete existing files</li>
|
||||
* <li>modify/replace files</li>
|
||||
* <li>move files</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return instance of {@link ModifyCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public ModifyCommandBuilder getModifyCommand() {
|
||||
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
|
||||
LOG.debug("create modify command for repository {}", repository);
|
||||
|
||||
return new ModifyCommandBuilder(provider.getModifyCommand(), workdirProvider, repository.getId(), eMail);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lookup command executes a lookup which returns additional information for the repository.
|
||||
*
|
||||
* @return instance of {@link LookupCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.10.0
|
||||
*/
|
||||
public LookupCommandBuilder getLookupCommand() {
|
||||
LOG.debug("create lookup command for repository {}", repository);
|
||||
return new LookupCommandBuilder(provider.getLookupCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* The full health check command inspects a repository in a way, that might take a while in contrast to the
|
||||
* light checks executed at startup.
|
||||
*
|
||||
* @return instance of {@link FullHealthCheckCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.17.0
|
||||
*/
|
||||
public FullHealthCheckCommandBuilder getFullCheckCommand() {
|
||||
LOG.debug("create full check command for repository {}", repository);
|
||||
return new FullHealthCheckCommandBuilder(provider.getFullHealthCheckCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* The mirror command creates a 'mirror' of an existing repository (specified by a URL) by copying all data
|
||||
* to the repository of this service. Therefore this repository has to be empty (otherwise the behaviour is
|
||||
* not specified).
|
||||
*
|
||||
* @return instance of {@link MirrorCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.19.0
|
||||
*/
|
||||
public MirrorCommandBuilder getMirrorCommand() {
|
||||
LOG.debug("create mirror command for repository {}", repository);
|
||||
return new MirrorCommandBuilder(provider.getMirrorCommand(), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock and unlock files.
|
||||
*
|
||||
* @return instance of {@link FileLockCommandBuilder}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.26.0
|
||||
*/
|
||||
public FileLockCommandBuilder getLockCommand() {
|
||||
LOG.debug("create lock command for repository {}", repository);
|
||||
return new FileLockCommandBuilder(provider.getFileLockCommand(), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details for a branch.
|
||||
*
|
||||
* @return instance of {@link BranchDetailsCommand}
|
||||
* @throws CommandNotSupportedException if the command is not supported
|
||||
* by the implementation of the repository service provider.
|
||||
* @since 2.28.0
|
||||
*/
|
||||
public BranchDetailsCommandBuilder getBranchDetailsCommand() {
|
||||
LOG.debug("create branch details command for repository {}", repository);
|
||||
return new BranchDetailsCommandBuilder(repository, provider.getBranchDetailsCommand(), cacheManager);
|
||||
}
|
||||
|
||||
public ChangesetsCommandBuilder getChangesetsCommand() {
|
||||
LOG.debug("create changesets command for repository {}", repository);
|
||||
return new ChangesetsCommandBuilder(repository, provider.getChangesetsCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the command is supported by the repository service.
|
||||
*
|
||||
* @param command command
|
||||
* @return true if the command is supported
|
||||
*/
|
||||
public boolean isSupported(Command command) {
|
||||
return provider.getSupportedCommands().contains(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the feature is supported by the repository service.
|
||||
*
|
||||
* @param feature feature
|
||||
* @return true if the feature is supported
|
||||
* @since 1.25
|
||||
*/
|
||||
public boolean isSupported(Feature feature) {
|
||||
return provider.getSupportedFeatures().contains(feature);
|
||||
}
|
||||
|
||||
public Stream<ScmProtocol> getSupportedProtocols() {
|
||||
return protocolProviders.stream()
|
||||
.filter(protocolProvider -> protocolProvider.getType().equals(getRepository().getType()))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.api;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
import jakarta.annotation.Nullable;
|
||||
import sonia.scm.repository.spi.RevertCommand;
|
||||
import sonia.scm.repository.spi.RevertCommandRequest;
|
||||
import sonia.scm.repository.util.AuthorUtil;
|
||||
import sonia.scm.user.DisplayUser;
|
||||
import sonia.scm.user.EMail;
|
||||
|
||||
/**
|
||||
* Applies a revert of a chosen changeset onto the given repository/branch combination.
|
||||
*
|
||||
* @since 3.8
|
||||
*/
|
||||
public final class RevertCommandBuilder {
|
||||
|
||||
private final RevertCommand command;
|
||||
private final RevertCommandRequest request;
|
||||
|
||||
@Nullable
|
||||
private final EMail email;
|
||||
|
||||
/**
|
||||
* @param command A {@link RevertCommand} implementation provided by some source.
|
||||
*/
|
||||
public RevertCommandBuilder(RevertCommand command, @Nullable EMail email) {
|
||||
this.command = command;
|
||||
this.email = email;
|
||||
this.request = new RevertCommandRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this to set the author of the revert commit manually. If this is omitted, the currently logged-in user will be
|
||||
* used instead. If the given user object does not have an email address, we will use {@link EMail} to compute a
|
||||
* fallback address.
|
||||
*
|
||||
* @param author Author entity.
|
||||
* @return This instance.
|
||||
*/
|
||||
public RevertCommandBuilder setAuthor(DisplayUser author) {
|
||||
request.setAuthor(AuthorUtil.createAuthorWithMailFallback(author, email));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obligatory value.
|
||||
*
|
||||
* @param revision Identifier of the revision.
|
||||
* @return This instance.
|
||||
*/
|
||||
public RevertCommandBuilder setRevision(String revision) {
|
||||
request.setRevision(revision);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an optional parameter. Not every SCM system supports branches.
|
||||
* If null or empty and supported by the SCM, the default branch of the repository shall be used.
|
||||
*
|
||||
* @param branch Name of the branch.
|
||||
* @return This instance.
|
||||
*/
|
||||
public RevertCommandBuilder setBranch(String branch) {
|
||||
request.setBranch(branch);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an optional parameter. If null or empty, a default message will be set.
|
||||
*
|
||||
* @param message Particular message.
|
||||
* @return This instance.
|
||||
*/
|
||||
public RevertCommandBuilder setMessage(String message) {
|
||||
request.setMessage(message);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the revert with the given builder parameters.
|
||||
*
|
||||
* @return {@link RevertCommandResult} with information about the executed revert.
|
||||
*/
|
||||
public RevertCommandResult execute() {
|
||||
AuthorUtil.setAuthorIfNotAvailable(request, email);
|
||||
Preconditions.checkArgument(request.isValid(), "Revert request is invalid, request was: %s", request);
|
||||
return command.revert(request);
|
||||
}
|
||||
|
||||
protected RevertCommandRequest getRequest() {
|
||||
return this.request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.api;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
|
||||
/**
|
||||
* Contains the result of an executed revert command.
|
||||
*
|
||||
* @since 3.8
|
||||
*/
|
||||
@Getter
|
||||
public class RevertCommandResult {
|
||||
|
||||
/**
|
||||
* The identifier of the revision after the applied revert.
|
||||
*/
|
||||
private final String revision;
|
||||
/**
|
||||
* A collection of files where conflicts occur.
|
||||
*/
|
||||
private final Collection<String> filesWithConflict;
|
||||
|
||||
/**
|
||||
* Creates a {@link RevertCommandResult}.
|
||||
*
|
||||
* @param revision revision identifier
|
||||
* @param filesWithConflict a collection of files where conflicts occur
|
||||
*/
|
||||
public RevertCommandResult(String revision, Collection<String> filesWithConflict) {
|
||||
this.revision = revision;
|
||||
this.filesWithConflict = filesWithConflict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate a successful revert.
|
||||
*
|
||||
* @param newHeadRevision id of the newly created revert
|
||||
* @return {@link RevertCommandResult}
|
||||
*/
|
||||
public static RevertCommandResult success(String newHeadRevision) {
|
||||
return new RevertCommandResult(newHeadRevision, emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate a failed revert.
|
||||
*
|
||||
* @param filesWithConflict collection of conflicting files
|
||||
* @return {@link RevertCommandResult}
|
||||
*/
|
||||
public static RevertCommandResult failure(Collection<String> filesWithConflict) {
|
||||
return new RevertCommandResult(null, new HashSet<>(filesWithConflict));
|
||||
}
|
||||
|
||||
/**
|
||||
* If this returns <code>true</code>, the revert was successful. If this returns <code>false</code>, there may have
|
||||
* been problems like a merge conflict after the revert.
|
||||
*/
|
||||
public boolean isSuccessful() {
|
||||
return filesWithConflict.isEmpty() && revision != null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import sonia.scm.repository.Feature;
|
||||
import sonia.scm.repository.api.Command;
|
||||
import sonia.scm.repository.api.CommandNotSupportedException;
|
||||
@@ -26,13 +27,17 @@ import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* This class is an extension base for SCM system providers to implement command functionalitites.
|
||||
* If unimplemented, the methods within this class throw {@link CommandNotSupportedException}. These are not supposed
|
||||
* to be called if unimplemented for an SCM system.
|
||||
*
|
||||
* @see sonia.scm.repository.api.RepositoryService
|
||||
* @since 1.17
|
||||
*/
|
||||
public abstract class RepositoryServiceProvider implements Closeable
|
||||
{
|
||||
@Slf4j
|
||||
public abstract class RepositoryServiceProvider implements Closeable {
|
||||
|
||||
|
||||
|
||||
public abstract Set<Command> getSupportedCommands();
|
||||
|
||||
|
||||
@@ -41,195 +46,118 @@ public abstract class RepositoryServiceProvider implements Closeable
|
||||
* free resources, close connections or release locks than you have to
|
||||
* override this method.
|
||||
*
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
|
||||
// should be implmentented from a service provider
|
||||
public void close() throws IOException {
|
||||
log.warn("warning: close() has been called without implementation from a service provider.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
public BlameCommand getBlameCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.BLAME);
|
||||
}
|
||||
|
||||
|
||||
public BranchesCommand getBranchesCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.BRANCHES);
|
||||
}
|
||||
|
||||
|
||||
public BranchCommand getBranchCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.BRANCH);
|
||||
}
|
||||
|
||||
|
||||
public BrowseCommand getBrowseCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.BROWSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.43
|
||||
*/
|
||||
public BundleCommand getBundleCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.BUNDLE);
|
||||
}
|
||||
|
||||
public CatCommand getCatCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.CAT);
|
||||
}
|
||||
|
||||
|
||||
public DiffCommand getDiffCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.DIFF);
|
||||
}
|
||||
|
||||
public DiffResultCommand getDiffResultCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.DIFF_RESULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.31
|
||||
*/
|
||||
public IncomingCommand getIncomingCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.INCOMING);
|
||||
}
|
||||
|
||||
|
||||
public LogCommand getLogCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.LOG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the corresponding {@link ModificationsCommand} implemented from the Plugins
|
||||
*
|
||||
* @return the corresponding {@link ModificationsCommand} implemented from the Plugins
|
||||
* @throws CommandNotSupportedException if there is no Implementation
|
||||
*/
|
||||
public ModificationsCommand getModificationsCommand() {
|
||||
throw new CommandNotSupportedException(Command.MODIFICATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.31
|
||||
*/
|
||||
public OutgoingCommand getOutgoingCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.OUTGOING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.31
|
||||
*/
|
||||
public PullCommand getPullCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.PULL);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.31
|
||||
*/
|
||||
public PushCommand getPushCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.PUSH);
|
||||
}
|
||||
|
||||
|
||||
public Set<Feature> getSupportedFeatures()
|
||||
{
|
||||
public Set<Feature> getSupportedFeatures() {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
|
||||
public TagsCommand getTagsCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.TAGS);
|
||||
public BlameCommand getBlameCommand() {
|
||||
throw new CommandNotSupportedException(Command.BLAME);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @since 2.11.0
|
||||
*/
|
||||
public TagCommand getTagCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.TAG);
|
||||
public BranchesCommand getBranchesCommand() {
|
||||
throw new CommandNotSupportedException(Command.BRANCHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.43
|
||||
*/
|
||||
public UnbundleCommand getUnbundleCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.UNBUNDLE);
|
||||
public BranchCommand getBranchCommand() {
|
||||
throw new CommandNotSupportedException(Command.BRANCH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.0
|
||||
*/
|
||||
public MergeCommand getMergeCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.MERGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.0
|
||||
*/
|
||||
public ModifyCommand getModifyCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.MODIFY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.10.0
|
||||
*/
|
||||
public LookupCommand getLookupCommand()
|
||||
{
|
||||
throw new CommandNotSupportedException(Command.LOOKUP);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.17.0
|
||||
*/
|
||||
public FullHealthCheckCommand getFullHealthCheckCommand() {
|
||||
throw new CommandNotSupportedException(Command.FULL_HEALTH_CHECK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.19.0
|
||||
*/
|
||||
public MirrorCommand getMirrorCommand() {
|
||||
throw new CommandNotSupportedException(Command.MIRROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.26.0
|
||||
*/
|
||||
public FileLockCommand getFileLockCommand() {
|
||||
throw new CommandNotSupportedException(Command.FILE_LOCK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 2.28.0
|
||||
*/
|
||||
public BranchDetailsCommand getBranchDetailsCommand() {
|
||||
throw new CommandNotSupportedException(Command.BRANCH_DETAILS);
|
||||
}
|
||||
|
||||
public BrowseCommand getBrowseCommand() {
|
||||
throw new CommandNotSupportedException(Command.BROWSE);
|
||||
}
|
||||
|
||||
public BundleCommand getBundleCommand() {
|
||||
throw new CommandNotSupportedException(Command.BUNDLE);
|
||||
}
|
||||
|
||||
public CatCommand getCatCommand() {
|
||||
throw new CommandNotSupportedException(Command.CAT);
|
||||
}
|
||||
|
||||
public ChangesetsCommand getChangesetsCommand() {
|
||||
throw new CommandNotSupportedException(Command.CHANGESETS);
|
||||
}
|
||||
|
||||
public DiffCommand getDiffCommand() {
|
||||
throw new CommandNotSupportedException(Command.DIFF);
|
||||
}
|
||||
|
||||
public DiffResultCommand getDiffResultCommand() {
|
||||
throw new CommandNotSupportedException(Command.DIFF_RESULT);
|
||||
}
|
||||
|
||||
public FileLockCommand getFileLockCommand() {
|
||||
throw new CommandNotSupportedException(Command.FILE_LOCK);
|
||||
}
|
||||
|
||||
public FullHealthCheckCommand getFullHealthCheckCommand() {
|
||||
throw new CommandNotSupportedException(Command.FULL_HEALTH_CHECK);
|
||||
}
|
||||
|
||||
public IncomingCommand getIncomingCommand() {
|
||||
throw new CommandNotSupportedException(Command.INCOMING);
|
||||
}
|
||||
|
||||
public LogCommand getLogCommand() {
|
||||
throw new CommandNotSupportedException(Command.LOG);
|
||||
}
|
||||
|
||||
public LookupCommand getLookupCommand() {
|
||||
throw new CommandNotSupportedException(Command.LOOKUP);
|
||||
}
|
||||
|
||||
public MergeCommand getMergeCommand() {
|
||||
throw new CommandNotSupportedException(Command.MERGE);
|
||||
}
|
||||
|
||||
public MirrorCommand getMirrorCommand() {
|
||||
throw new CommandNotSupportedException(Command.MIRROR);
|
||||
}
|
||||
|
||||
public ModificationsCommand getModificationsCommand() {
|
||||
throw new CommandNotSupportedException(Command.MODIFICATIONS);
|
||||
}
|
||||
|
||||
public ModifyCommand getModifyCommand() {
|
||||
throw new CommandNotSupportedException(Command.MODIFY);
|
||||
}
|
||||
|
||||
public OutgoingCommand getOutgoingCommand() {
|
||||
throw new CommandNotSupportedException(Command.OUTGOING);
|
||||
}
|
||||
|
||||
public PullCommand getPullCommand() {
|
||||
throw new CommandNotSupportedException(Command.PULL);
|
||||
}
|
||||
|
||||
public PushCommand getPushCommand() {
|
||||
throw new CommandNotSupportedException(Command.PUSH);
|
||||
}
|
||||
|
||||
public RevertCommand getRevertCommand() {
|
||||
throw new CommandNotSupportedException(Command.REVERT);
|
||||
}
|
||||
|
||||
public TagsCommand getTagsCommand() {
|
||||
throw new CommandNotSupportedException(Command.TAGS);
|
||||
}
|
||||
|
||||
public TagCommand getTagCommand() {
|
||||
throw new CommandNotSupportedException(Command.TAG);
|
||||
}
|
||||
|
||||
public UnbundleCommand getUnbundleCommand() {
|
||||
throw new CommandNotSupportedException(Command.UNBUNDLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
/**
|
||||
* @deprecated This interface may get removed at some point in the future.
|
||||
* @since 1.17
|
||||
*/
|
||||
@Deprecated(since = "3.8")
|
||||
public interface Resetable
|
||||
{
|
||||
|
||||
public void reset();
|
||||
void reset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
import sonia.scm.repository.api.RevertCommandResult;
|
||||
|
||||
/**
|
||||
* Removes the changes from a particular changeset as a revert. This, in turn, will result a new changeset.
|
||||
*
|
||||
* @since 3.8
|
||||
*/
|
||||
public interface RevertCommand {
|
||||
|
||||
/**
|
||||
* Executes a revert.
|
||||
* @param request parameter set for this command.
|
||||
* @see RevertCommand
|
||||
* @return result set of the executed command (see {@link RevertCommandResult}).
|
||||
*/
|
||||
RevertCommandResult revert(RevertCommandRequest request);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import sonia.scm.Validateable;
|
||||
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.repository.util.AuthorUtil.CommandWithAuthor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* This class contains the information to run {@link RevertCommand#revert(RevertCommandRequest)}.
|
||||
|
||||
* @since 3.8
|
||||
*/
|
||||
@Setter
|
||||
@ToString
|
||||
public class RevertCommandRequest implements Validateable, CommandWithAuthor {
|
||||
|
||||
@Getter
|
||||
private Person author;
|
||||
|
||||
@Getter
|
||||
private String revision;
|
||||
|
||||
/**
|
||||
* Reverts can be signed with a GPG key. This is set as <tt>true</tt> by default.
|
||||
*/
|
||||
@Getter
|
||||
private boolean sign = true;
|
||||
|
||||
private String branch;
|
||||
|
||||
private String message;
|
||||
|
||||
public Optional<String> getBranch() {
|
||||
return Optional.ofNullable(branch);
|
||||
}
|
||||
|
||||
public Optional<String> getMessage() {
|
||||
return Optional.ofNullable(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
boolean validBranch = branch == null || !branch.isEmpty();
|
||||
boolean validMessage = message == null || !message.isEmpty();
|
||||
return revision != null && author != null && validBranch && validMessage;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,29 +20,61 @@ import jakarta.annotation.Nullable;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.user.DisplayUser;
|
||||
import sonia.scm.user.EMail;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
/**
|
||||
* Contains convenience methods to manage {@link CommandWithAuthor} classes.
|
||||
*/
|
||||
public class AuthorUtil {
|
||||
|
||||
/**
|
||||
* @see AuthorUtil#createAuthorFromSubject(EMail)
|
||||
* @param request {@link CommandWithAuthor}
|
||||
*/
|
||||
public static void setAuthorIfNotAvailable(CommandWithAuthor request) {
|
||||
setAuthorIfNotAvailable(request, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see AuthorUtil#createAuthorFromSubject(EMail)
|
||||
* @param request {@link CommandWithAuthor}
|
||||
* @param eMail {@link EMail}
|
||||
*/
|
||||
public static void setAuthorIfNotAvailable(CommandWithAuthor request, @Nullable EMail eMail) {
|
||||
if (request.getAuthor() == null) {
|
||||
request.setAuthor(createAuthorFromSubject(eMail));
|
||||
}
|
||||
}
|
||||
|
||||
private static Person createAuthorFromSubject(@Nullable EMail eMail) {
|
||||
Subject subject = SecurityUtils.getSubject();
|
||||
User user = subject.getPrincipals().oneByType(User.class);
|
||||
/**
|
||||
* Depending on the mail input, the {@link Person} is either created by the given nullable {@link EMail}
|
||||
* or the information from {@link DisplayUser} if the mail remains null.
|
||||
* @param user {@link DisplayUser}
|
||||
* @param eMail (nullable) {@link EMail}
|
||||
* @return {@link Person}
|
||||
*/
|
||||
public static Person createAuthorWithMailFallback(DisplayUser user, @Nullable EMail eMail) {
|
||||
String name = user.getDisplayName();
|
||||
String mailAddress = eMail != null ? eMail.getMailOrFallback(user) : user.getMail();
|
||||
return new Person(name, mailAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an author from the Apache Shiro {@link Subject} given by the {@link SecurityUtils}.
|
||||
* @param eMail {@link EMail}
|
||||
* @return {@link Person}
|
||||
*/
|
||||
private static Person createAuthorFromSubject(@Nullable EMail eMail) {
|
||||
Subject subject = SecurityUtils.getSubject();
|
||||
User user = subject.getPrincipals().oneByType(User.class);
|
||||
return createAuthorWithMailFallback(DisplayUser.from(user), eMail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command whose execution includes an author as a {@link Person}.
|
||||
*/
|
||||
public interface CommandWithAuthor {
|
||||
Person getAuthor();
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
|
||||
package sonia.scm.user;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import sonia.scm.ReducedModelObject;
|
||||
|
||||
@EqualsAndHashCode
|
||||
public class DisplayUser implements ReducedModelObject {
|
||||
|
||||
private final String id;
|
||||
|
||||
@@ -40,6 +40,7 @@ public class VndMediaType {
|
||||
public static final String REPOSITORY_PERMISSION = PREFIX + "repositoryPermission" + SUFFIX;
|
||||
public static final String CHANGESET = PREFIX + "changeset" + SUFFIX;
|
||||
public static final String CHANGESET_COLLECTION = PREFIX + "changesetCollection" + SUFFIX;
|
||||
public static final String REVERT = PREFIX + "revert" + SUFFIX;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.api;
|
||||
|
||||
import org.apache.shiro.subject.PrincipalCollection;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import org.apache.shiro.util.ThreadContext;
|
||||
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.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.repository.spi.RevertCommand;
|
||||
import sonia.scm.repository.spi.RevertCommandRequest;
|
||||
import sonia.scm.user.DisplayUser;
|
||||
import sonia.scm.user.EMail;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RevertCommandBuilderTest {
|
||||
|
||||
@Mock
|
||||
private RevertCommand revertCommand;
|
||||
@Mock
|
||||
private EMail eMail;
|
||||
|
||||
@InjectMocks
|
||||
private RevertCommandBuilder revertCommandBuilder;
|
||||
|
||||
@BeforeEach
|
||||
void prepareCommandBuilder() {
|
||||
revertCommandBuilder.setRevision("irrelevant");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseMailAddressFromEMailFallback() {
|
||||
User user = new User("dent", "Arthur Dent", null);
|
||||
DisplayUser author = DisplayUser.from(user);
|
||||
when(eMail.getMailOrFallback(author)).thenReturn("dent@hitchhiker.com");
|
||||
revertCommandBuilder.setAuthor(author);
|
||||
revertCommandBuilder.execute();
|
||||
|
||||
verify(revertCommand).revert(argThat(revertCommandRequest -> {
|
||||
assertThat(revertCommandRequest.getAuthor().getMail()).isEqualTo("dent@hitchhiker.com");
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSetAuthorFromShiroSubjectIfNotSet() {
|
||||
User user = new User("dent", "Arthur Dent", null);DisplayUser author = DisplayUser.from(user);
|
||||
when(eMail.getMailOrFallback(author)).thenReturn("dent@hitchhiker.com");
|
||||
mockLoggedInUser(user);
|
||||
revertCommandBuilder.execute();
|
||||
RevertCommandRequest request = revertCommandBuilder.getRequest();
|
||||
|
||||
assertThat(request.getAuthor().getName()).isEqualTo("Arthur Dent");
|
||||
assertThat(request.getAuthor().getMail()).isEqualTo("dent@hitchhiker.com");
|
||||
|
||||
mockLogout();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSetAllFieldsInRequest() {
|
||||
User user = new User("dent", "Arthur Dent", null);
|
||||
DisplayUser author = DisplayUser.from(user);
|
||||
when(eMail.getMailOrFallback(author)).thenReturn("dent@hitchhiker.com");
|
||||
revertCommandBuilder.setAuthor(author);
|
||||
revertCommandBuilder.setBranch("someBranch");
|
||||
revertCommandBuilder.setMessage("someMessage");
|
||||
|
||||
RevertCommandRequest request = revertCommandBuilder.getRequest();
|
||||
|
||||
assertThat(request.getAuthor().getName()).isEqualTo(author.getDisplayName());
|
||||
assertThat(request.getBranch()).contains("someBranch");
|
||||
assertThat(request.getMessage()).contains("someMessage");
|
||||
assertThat(request.getRevision()).isEqualTo("irrelevant");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotExecuteInvalidRequestDueToEmptyBranch() {
|
||||
User user = new User("dent", "Arthur Dent", "dent@hitchhiker.com");
|
||||
revertCommandBuilder.setAuthor(DisplayUser.from(user));
|
||||
revertCommandBuilder.setBranch("");
|
||||
assertThatThrownBy(() -> revertCommandBuilder.execute())
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Revert request is invalid, request was: RevertCommandRequest(author=Arthur Dent, revision=irrelevant, sign=true, branch=Optional[], message=Optional.empty)");
|
||||
}
|
||||
|
||||
private void mockLoggedInUser(User loggedInUser) {
|
||||
Subject subject = mock(Subject.class);
|
||||
ThreadContext.bind(subject);
|
||||
PrincipalCollection principals = mock(PrincipalCollection.class);
|
||||
when(subject.getPrincipals()).thenReturn(principals);
|
||||
when(principals.oneByType(User.class)).thenReturn(loggedInUser);
|
||||
}
|
||||
|
||||
private void mockLogout() {
|
||||
ThreadContext.unbindSubject();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import org.mockito.Answers;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.user.DisplayUser;
|
||||
import sonia.scm.user.EMail;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
@@ -54,9 +55,9 @@ class AuthorUtilTest {
|
||||
@Test
|
||||
void shouldCreateMailAddressFromEmail() {
|
||||
User trillian = new User("trillian");
|
||||
when(subject.getPrincipals().oneByType(User.class)).thenReturn(trillian);
|
||||
when(eMail.getMailOrFallback(trillian)).thenReturn("tricia@hitchhicker.com");
|
||||
|
||||
when(subject.getPrincipals().oneByType(User.class)).thenReturn(trillian);
|
||||
when(eMail.getMailOrFallback(DisplayUser.from(trillian))).thenReturn("tricia@hitchhicker.com");
|
||||
Command command = new Command(null);
|
||||
AuthorUtil.setAuthorIfNotAvailable(command, eMail);
|
||||
|
||||
|
||||
@@ -71,10 +71,10 @@ import static java.util.Optional.ofNullable;
|
||||
|
||||
public final class GitUtil {
|
||||
|
||||
private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider();
|
||||
public static final String REF_HEAD = "HEAD";
|
||||
public static final String REF_HEAD_PREFIX = "refs/heads/";
|
||||
public static final String REF_MAIN = "main";
|
||||
private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider();
|
||||
private static final String DIRECTORY_DOTGIT = ".git";
|
||||
private static final String DIRECTORY_OBJETCS = "objects";
|
||||
private static final String DIRECTORY_REFS = "refs";
|
||||
@@ -84,15 +84,13 @@ public final class GitUtil {
|
||||
private static final String REMOTE_REF = "refs/remote/scm/%s/%s";
|
||||
private static final int TIMEOUT = 5;
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GitUtil.class);
|
||||
private static final String REF_SPEC = "refs/heads/*:refs/heads/*";
|
||||
|
||||
private static final String GPG_HEADER = "-----BEGIN PGP SIGNATURE-----";
|
||||
|
||||
private GitUtil() {
|
||||
}
|
||||
|
||||
|
||||
public static void close(org.eclipse.jgit.lib.Repository repo) {
|
||||
if (repo != null) {
|
||||
repo.close();
|
||||
@@ -181,7 +179,6 @@ public final class GitUtil {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static String getBranch(Ref ref) {
|
||||
String branch = null;
|
||||
|
||||
@@ -234,7 +231,6 @@ public final class GitUtil {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo,
|
||||
String branchName)
|
||||
throws IOException {
|
||||
@@ -291,22 +287,43 @@ public final class GitUtil {
|
||||
/**
|
||||
* Returns the commit for the given ref.
|
||||
* If the given ref is for a tag, the commit that this tag belongs to is returned instead.
|
||||
*
|
||||
* @param repository jgit repository
|
||||
* @param revWalk rev walk
|
||||
* @param ref commit/tag ref
|
||||
* @return {@link RevCommit}
|
||||
* @throws IOException exception
|
||||
*/
|
||||
public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository,
|
||||
RevWalk revWalk, Ref ref)
|
||||
throws IOException {
|
||||
RevCommit commit = null;
|
||||
ObjectId id = ref.getPeeledObjectId();
|
||||
|
||||
if (id == null) {
|
||||
id = ref.getObjectId();
|
||||
}
|
||||
|
||||
return getCommit(repository, revWalk, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the commit for the given object id. The id is expected to be a commit and not a tag.
|
||||
*
|
||||
* @param repository jgit repository
|
||||
* @param revWalk rev walk
|
||||
* @param id commit id
|
||||
* @return {@link RevCommit}
|
||||
* @throws IOException exception
|
||||
* @since 3.8.0
|
||||
*/
|
||||
public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository,
|
||||
RevWalk revWalk, ObjectId id) throws IOException {
|
||||
RevCommit commit = null;
|
||||
|
||||
if (id != null) {
|
||||
if (revWalk == null) {
|
||||
revWalk = new RevWalk(repository);
|
||||
}
|
||||
|
||||
commit = revWalk.parseCommit(id);
|
||||
}
|
||||
|
||||
@@ -330,7 +347,6 @@ public final class GitUtil {
|
||||
return tag;
|
||||
}
|
||||
|
||||
|
||||
public static long getCommitTime(RevCommit commit) {
|
||||
long date = commit.getCommitTime();
|
||||
|
||||
@@ -339,7 +355,6 @@ public final class GitUtil {
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
public static String getId(AnyObjectId objectId) {
|
||||
String id = Util.EMPTY_STRING;
|
||||
|
||||
@@ -350,7 +365,6 @@ public final class GitUtil {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository,
|
||||
ObjectId id)
|
||||
throws IOException {
|
||||
@@ -415,7 +429,6 @@ public final class GitUtil {
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
|
||||
public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo,
|
||||
String revision)
|
||||
throws IOException {
|
||||
@@ -430,7 +443,6 @@ public final class GitUtil {
|
||||
return revId;
|
||||
}
|
||||
|
||||
|
||||
public static String getScmRemoteRefName(Repository repository,
|
||||
Ref localBranch) {
|
||||
return getScmRemoteRefName(repository, localBranch.getName());
|
||||
@@ -463,7 +475,6 @@ public final class GitUtil {
|
||||
return tagName;
|
||||
}
|
||||
|
||||
|
||||
public static String getTagName(Ref ref) {
|
||||
String name = ref.getName();
|
||||
|
||||
@@ -474,8 +485,6 @@ public final class GitUtil {
|
||||
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();
|
||||
|
||||
@@ -49,7 +49,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider {
|
||||
Command.MIRROR,
|
||||
Command.FILE_LOCK,
|
||||
Command.BRANCH_DETAILS,
|
||||
Command.CHANGESETS
|
||||
Command.CHANGESETS,
|
||||
Command.REVERT
|
||||
);
|
||||
|
||||
protected static final Set<Feature> FEATURES = EnumSet.of(
|
||||
@@ -184,6 +185,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider {
|
||||
return injector.getInstance(GitChangesetsCommand.Factory.class).create(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RevertCommand getRevertCommand() {
|
||||
return injector.getInstance(GitRevertCommand.Factory.class).create(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Command> getSupportedCommands() {
|
||||
return COMMANDS;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.assistedinject.Assisted;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jgit.api.errors.CanceledException;
|
||||
import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.merge.MergeStrategy;
|
||||
import org.eclipse.jgit.merge.RecursiveMerger;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
import sonia.scm.NoChangesMadeException;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.repository.GitUtil;
|
||||
import sonia.scm.repository.MultipleParentsNotAllowedException;
|
||||
import sonia.scm.repository.NoParentException;
|
||||
import sonia.scm.repository.RepositoryManager;
|
||||
import sonia.scm.repository.api.RevertCommandResult;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
|
||||
@Slf4j
|
||||
public class GitRevertCommand extends AbstractGitCommand implements RevertCommand {
|
||||
|
||||
private final RepositoryManager repositoryManager;
|
||||
private final GitRepositoryHookEventFactory eventFactory;
|
||||
|
||||
@Inject
|
||||
GitRevertCommand(@Assisted GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) {
|
||||
super(context);
|
||||
this.repositoryManager = repositoryManager;
|
||||
this.eventFactory = eventFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RevertCommandResult revert(RevertCommandRequest request) {
|
||||
log.debug("revert {} on {} in repository {}",
|
||||
request.getRevision(),
|
||||
request.getBranch().orElse("default branch"),
|
||||
repository.getName());
|
||||
|
||||
try (Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
|
||||
ObjectId sourceRevision = getSourceRevision(request, jRepository, repository);
|
||||
ObjectId targetRevision = getTargetRevision(request, jRepository, repository);
|
||||
|
||||
RevCommit parent = getParentRevision(revWalk, sourceRevision, jRepository);
|
||||
|
||||
RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(jRepository, true);
|
||||
merger.setBase(sourceRevision);
|
||||
|
||||
boolean mergeSucceeded = merger.merge(targetRevision, parent);
|
||||
|
||||
if (!mergeSucceeded) {
|
||||
log.info("revert merge fail: {} on {} in repository {}",
|
||||
sourceRevision.getName(), targetRevision.getName(), repository.getName());
|
||||
return RevertCommandResult.failure(MergeHelper.getFailingPaths(merger));
|
||||
}
|
||||
|
||||
ObjectId oldTreeId = revWalk.parseCommit(targetRevision).getTree().toObjectId();
|
||||
ObjectId newTreeId = merger.getResultTreeId();
|
||||
if (oldTreeId.equals(newTreeId)) {
|
||||
throw new NoChangesMadeException(repository);
|
||||
}
|
||||
|
||||
log.debug("revert {} on {} in repository {} successful, preparing commit",
|
||||
sourceRevision.getName(), targetRevision.getName(), repository.getName());
|
||||
CommitHelper commitHelper = new CommitHelper(context, repositoryManager, eventFactory);
|
||||
ObjectId commitId = commitHelper.createCommit(
|
||||
newTreeId,
|
||||
request.getAuthor(),
|
||||
request.getAuthor(),
|
||||
determineMessage(request, GitUtil.getCommit(jRepository, revWalk, sourceRevision)),
|
||||
request.isSign(),
|
||||
targetRevision
|
||||
);
|
||||
|
||||
commitHelper.updateBranch(
|
||||
request.getBranch().orElseGet(() -> context.getConfig().getDefaultBranch()), commitId, targetRevision
|
||||
);
|
||||
|
||||
return RevertCommandResult.success(commitId.getName());
|
||||
|
||||
} catch (CanceledException | IOException | UnsupportedSigningFormatException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectId getSourceRevision(RevertCommandRequest request,
|
||||
Repository jRepository,
|
||||
sonia.scm.repository.Repository sRepository) throws IOException {
|
||||
ObjectId sourceRevision = GitUtil.getRevisionId(jRepository, request.getRevision());
|
||||
|
||||
if (sourceRevision == null) {
|
||||
log.error("source revision not found!");
|
||||
throw NotFoundException.notFound(entity(ObjectId.class, request.getRevision()).in(sRepository));
|
||||
}
|
||||
|
||||
log.debug("got source revision {} for repository {}", sourceRevision.getName(), jRepository.getIdentifier());
|
||||
return sourceRevision;
|
||||
}
|
||||
|
||||
private ObjectId getTargetRevision(RevertCommandRequest request,
|
||||
Repository jRepository,
|
||||
sonia.scm.repository.Repository sRepository) throws IOException {
|
||||
if (request.getBranch().isEmpty() || request.getBranch().get().isEmpty()) {
|
||||
ObjectId targetRevision = GitUtil.getRepositoryHead(jRepository);
|
||||
log.debug("given target branch is empty, returning HEAD revision for repository {}", jRepository.getIdentifier());
|
||||
return targetRevision;
|
||||
}
|
||||
|
||||
ObjectId targetRevision = GitUtil.getRevisionId(jRepository, request.getBranch().get());
|
||||
if (targetRevision == null) {
|
||||
log.error("target revision not found!");
|
||||
throw NotFoundException.notFound(entity(ObjectId.class, request.getBranch().get()).in(sRepository));
|
||||
}
|
||||
|
||||
log.debug("got target revision {} for repository {}", targetRevision.getName(), jRepository.getIdentifier());
|
||||
return targetRevision;
|
||||
}
|
||||
|
||||
private RevCommit getParentRevision(RevWalk revWalk, ObjectId sourceRevision, Repository jRepository) throws IOException {
|
||||
RevCommit source = revWalk.parseCommit(sourceRevision);
|
||||
int sourceParents = source.getParentCount();
|
||||
|
||||
if (sourceParents == 0) {
|
||||
throw new NoParentException(sourceRevision.getName());
|
||||
} else if (sourceParents > 1) {
|
||||
throw new MultipleParentsNotAllowedException(sourceRevision.getName());
|
||||
}
|
||||
|
||||
RevCommit parent = source.getParent(0);
|
||||
|
||||
log.debug("got parent revision {} of revision {} for repository {}", parent.getName(), sourceRevision.getName(), jRepository.getIdentifier());
|
||||
return parent;
|
||||
}
|
||||
|
||||
private String determineMessage(RevertCommandRequest request, RevCommit revertedCommit) {
|
||||
return request.getMessage().orElseGet(() -> {
|
||||
log.debug("no custom message given, choose default message");
|
||||
return String.format("""
|
||||
Revert "%s"
|
||||
|
||||
This reverts commit %s.""", revertedCommit.getShortMessage(), revertedCommit.getId().getName());
|
||||
});
|
||||
}
|
||||
|
||||
public interface Factory {
|
||||
RevertCommand create(GitContext context);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import org.eclipse.jgit.api.errors.CanceledException;
|
||||
import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.merge.RecursiveMerger;
|
||||
import org.eclipse.jgit.merge.ResolveMerger;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
@@ -80,6 +81,15 @@ class MergeHelper {
|
||||
this.message = request.getMessage();
|
||||
}
|
||||
|
||||
static Collection<String> getFailingPaths(ResolveMerger merger) {
|
||||
return merger.getMergeResults()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.filter(entry -> entry.getValue().containsConflicts())
|
||||
.map(Map.Entry::getKey)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ObjectId getTargetRevision() {
|
||||
return targetRevision;
|
||||
}
|
||||
@@ -107,15 +117,6 @@ class MergeHelper {
|
||||
}
|
||||
}
|
||||
|
||||
Collection<String> getFailingPaths(ResolveMerger merger) {
|
||||
return merger.getMergeResults()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.filter(entry -> entry.getValue().containsConflicts())
|
||||
.map(Map.Entry::getKey)
|
||||
.toList();
|
||||
}
|
||||
|
||||
boolean isMergedInto(ObjectId baseRevision, ObjectId revisionToCheck) {
|
||||
try (RevWalk revWalk = new RevWalk(context.open())) {
|
||||
RevCommit baseCommit = revWalk.parseCommit(baseRevision);
|
||||
|
||||
@@ -57,6 +57,7 @@ import sonia.scm.repository.spi.GitModifyCommand;
|
||||
import sonia.scm.repository.spi.GitOutgoingCommand;
|
||||
import sonia.scm.repository.spi.GitPullCommand;
|
||||
import sonia.scm.repository.spi.GitPushCommand;
|
||||
import sonia.scm.repository.spi.GitRevertCommand;
|
||||
import sonia.scm.repository.spi.GitTagCommand;
|
||||
import sonia.scm.repository.spi.GitTagsCommand;
|
||||
import sonia.scm.repository.spi.GitUnbundleCommand;
|
||||
@@ -70,6 +71,7 @@ import sonia.scm.repository.spi.OutgoingCommand;
|
||||
import sonia.scm.repository.spi.PostReceiveRepositoryHookEventFactory;
|
||||
import sonia.scm.repository.spi.PullCommand;
|
||||
import sonia.scm.repository.spi.PushCommand;
|
||||
import sonia.scm.repository.spi.RevertCommand;
|
||||
import sonia.scm.repository.spi.SimpleGitWorkingCopyFactory;
|
||||
import sonia.scm.repository.spi.TagCommand;
|
||||
import sonia.scm.repository.spi.TagsCommand;
|
||||
@@ -119,7 +121,6 @@ public class GitServletModule extends ServletModule {
|
||||
install(new FactoryModuleBuilder().implement(FileLockCommand.class, GitFileLockCommand.class).build(GitFileLockCommand.Factory.class));
|
||||
install(new FactoryModuleBuilder().implement(BranchDetailsCommand.class, GitBranchDetailsCommand.class).build(GitBranchDetailsCommand.Factory.class));
|
||||
install(new FactoryModuleBuilder().implement(ChangesetsCommand.class, GitChangesetsCommand.class).build(GitChangesetsCommand.Factory.class));
|
||||
|
||||
|
||||
install(new FactoryModuleBuilder().implement(RevertCommand.class, GitRevertCommand.class).build(GitRevertCommand.Factory.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,53 +18,49 @@ package sonia.scm.repository.spi;
|
||||
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider;
|
||||
import sonia.scm.repository.GitConfig;
|
||||
import sonia.scm.repository.GitRepositoryConfig;
|
||||
import sonia.scm.store.InMemoryConfigurationStoreFactory;
|
||||
import sonia.scm.store.InMemoryByteConfigurationStoreFactory;
|
||||
|
||||
|
||||
public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase
|
||||
{
|
||||
public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase {
|
||||
|
||||
@After
|
||||
public void close()
|
||||
{
|
||||
private GitContext context;
|
||||
|
||||
@After
|
||||
@AfterEach
|
||||
public void close() {
|
||||
if (context != null) {
|
||||
context.setConfig(new GitRepositoryConfig());
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected GitContext createContext()
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
protected GitContext createContext() {
|
||||
return createContext("main");
|
||||
}
|
||||
|
||||
protected GitContext createContext(String defaultBranch) {
|
||||
if (context == null) {
|
||||
GitConfig config = new GitConfig();
|
||||
config.setDefaultBranch("master");
|
||||
context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), config);
|
||||
config.setDefaultBranch(defaultBranch);
|
||||
GitRepositoryConfigStoreProvider storeProvider = new GitRepositoryConfigStoreProvider(new InMemoryByteConfigurationStoreFactory());
|
||||
storeProvider.setDefaultBranch(repository, defaultBranch);
|
||||
context = new GitContext(repositoryDirectory, repository, storeProvider, config);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected String getType()
|
||||
{
|
||||
protected String getType() {
|
||||
return "git";
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected String getZippedRepositoryResource()
|
||||
{
|
||||
protected String getZippedRepositoryResource() {
|
||||
return "sonia/scm/repository/spi/scm-git-spi-test.zip";
|
||||
}
|
||||
|
||||
//~--- fields ---------------------------------------------------------------
|
||||
|
||||
private GitContext context;
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ public class GitBranchesCommandTest extends AbstractGitCommandTestBase {
|
||||
|
||||
List<Branch> branches = branchesCommand.getBranches();
|
||||
|
||||
assertThat(findBranch(branches, "master")).isEqualTo(
|
||||
assertThat(findBranch(branches, "main")).isEqualTo(
|
||||
defaultBranch(
|
||||
"master",
|
||||
"main",
|
||||
"fcd0ef1831e4002ac43ea539f4094334c79ea9ec",
|
||||
1339428655000L,
|
||||
new Person("Zaphod Beeblebrox", "zaphod.beeblebrox@hitchhiker.com")
|
||||
|
||||
@@ -44,7 +44,7 @@ public class GitBrowseCommand_BrokenSubmoduleTest extends AbstractGitCommandTest
|
||||
|
||||
@Before
|
||||
public void createCommand() {
|
||||
command = new GitBrowseCommand(createContext(), lfsBlobStoreFactory, synchronousExecutor());
|
||||
command = new GitBrowseCommand(createContext("master"), lfsBlobStoreFactory, synchronousExecutor());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -71,6 +71,6 @@ public class GitBrowseCommand_RecursiveDirectoryNameTest extends AbstractGitComm
|
||||
}
|
||||
|
||||
private GitBrowseCommand createCommand() {
|
||||
return new GitBrowseCommand(createContext(), lfsBlobStoreFactory, synchronousExecutor());
|
||||
return new GitBrowseCommand(createContext("master"), lfsBlobStoreFactory, synchronousExecutor());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
|
||||
assertEquals("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1", result.getChangesets().get(1).getId());
|
||||
assertEquals("592d797cd36432e591416e8b2b98154f4f163411", result.getChangesets().get(2).getId());
|
||||
assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", result.getChangesets().get(3).getId());
|
||||
assertEquals("master", result.getBranchName());
|
||||
assertEquals("main", result.getBranchName());
|
||||
assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty()));
|
||||
|
||||
// set default branch and fetch again
|
||||
@@ -271,15 +271,6 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
|
||||
assertEquals("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", c.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFindDefaultBranchFromHEAD() throws Exception {
|
||||
setRepositoryHeadReference("ref: refs/heads/test-branch");
|
||||
|
||||
ChangesetPagingResult changesets = createCommand().getChangesets(new LogCommandRequest());
|
||||
|
||||
assertEquals("test-branch", changesets.getBranchName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFindMasterBranchWhenHEADisNoRef() throws Exception {
|
||||
setRepositoryHeadReference("592d797cd36432e591416e8b2b98154f4f163411");
|
||||
|
||||
@@ -67,7 +67,7 @@ class GitModifyCommandTestBase extends AbstractGitCommandTestBase {
|
||||
RepositoryHookEvent postReceiveEvent = mockEvent(POST_RECEIVE);
|
||||
when(eventFactory.createPostReceiveEvent(any(), any(), any(), any())).thenReturn(postReceiveEvent);
|
||||
return new GitModifyCommand(
|
||||
createContext(),
|
||||
createContext("master"),
|
||||
lfsBlobStoreFactory,
|
||||
repositoryManager,
|
||||
eventFactory
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.spi;
|
||||
|
||||
import org.apache.shiro.subject.SimplePrincipalCollection;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import org.eclipse.jgit.lib.GpgConfig;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.PersonIdent;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.lib.Signers;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
import org.github.sdorra.jse.ShiroExtension;
|
||||
import org.github.sdorra.jse.SubjectAware;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.NoChangesMadeException;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.repository.GitTestHelper;
|
||||
import sonia.scm.repository.GitUtil;
|
||||
import sonia.scm.repository.MultipleParentsNotAllowedException;
|
||||
import sonia.scm.repository.NoParentException;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.repository.RepositoryManager;
|
||||
import sonia.scm.repository.api.RevertCommandResult;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
|
||||
class GitRevertCommandTest extends AbstractGitCommandTestBase {
|
||||
|
||||
static final String HEAD_REVISION = "18e22df410df66f027dc49bf0f229f4b9efb8ce5";
|
||||
static final String HEAD_MINUS_0_REVISION = "9d39c9f59030fd4e3d37e1d3717bcca43a9a5eef";
|
||||
static final String CONFLICTING_TARGET_BRANCH = "conflictingTargetBranch";
|
||||
static final String CONFLICTING_SOURCE_REVISION = "0d5be1f22687d75916c82ce10eb592375ba0fb21";
|
||||
static final String PARENTLESS_REVISION = "190bc4670197edeb724f0ee1e49d3a5307635228";
|
||||
static final String DIVERGING_BRANCH = "divergingBranch";
|
||||
static final String DIVERGING_MAIN_LATEST_ANCESTOR = "0d5be1f22687d75916c82ce10eb592375ba0fb21";
|
||||
static final String DIVERGING_BRANCH_LATEST_COMMIT = "e77fd7c8cd45be992e19a6d22170ead4fcd5f9ce";
|
||||
static final String MERGED_REVISION = "00da9cca94a507346c5b8284983f8a69840cc277";
|
||||
|
||||
@Mock
|
||||
RepositoryManager repositoryManager;
|
||||
@Mock
|
||||
GitRepositoryHookEventFactory gitRepositoryHookEventFactory;
|
||||
|
||||
@Override
|
||||
protected String getZippedRepositoryResource() {
|
||||
return "sonia/scm/repository/spi/scm-git-spi-revert-test.zip";
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Revert {
|
||||
|
||||
@BeforeAll
|
||||
public static void setSigner() {
|
||||
Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner());
|
||||
}
|
||||
|
||||
/**
|
||||
* We expect the newly created revision to be merged into the given branch.
|
||||
*/
|
||||
@Test
|
||||
void shouldBeTipOfHeadBranchAfterRevert() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open()) {
|
||||
assertThat(GitUtil.getBranchId(jRepository, "main").getObjectId().getName()).isEqualTo(result.getRevision());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBeTipOfDifferentBranchAfterRevert() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(DIVERGING_MAIN_LATEST_ANCESTOR);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch(DIVERGING_BRANCH);
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open()) {
|
||||
assertThat(GitUtil.getBranchId(jRepository, DIVERGING_BRANCH).getObjectId().getName()).isEqualTo(result.getRevision());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotRevertWithoutChange() {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
|
||||
command.revert(request);
|
||||
|
||||
assertThrows(NoChangesMadeException.class, () -> command.revert(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverting this very commit.
|
||||
*/
|
||||
@Test
|
||||
void shouldRevertHeadCommit() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch("main");
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
assertThat(result.isSuccessful()).isTrue();
|
||||
try (GitContext context = createContext()) {
|
||||
GitDiffCommand diffCommand = new GitDiffCommand(context);
|
||||
DiffCommandRequest diffRequest = new DiffCommandRequest();
|
||||
diffRequest.setRevision(result.getRevision());
|
||||
diffRequest.setPath("hitchhiker");
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
diffCommand.getDiffResult(diffRequest).accept(baos);
|
||||
assertThat(baos.toString()).contains("George Lucas\n-Darth Vader");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverting this very commit.
|
||||
* The branch is not explicitly set, so we expect the default branch.
|
||||
*/
|
||||
@Test
|
||||
void shouldRevertHeadCommitImplicitly() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
assertThat(result.isSuccessful()).isTrue();
|
||||
try (GitContext context = createContext()) {
|
||||
GitDiffCommand diffCommand = new GitDiffCommand(context);
|
||||
DiffCommandRequest diffRequest = new DiffCommandRequest();
|
||||
diffRequest.setRevision(result.getRevision());
|
||||
diffRequest.setPath("hitchhiker");
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
diffCommand.getDiffResult(diffRequest).accept(baos);
|
||||
assertThat(baos.toString()).contains("George Lucas\n-Darth Vader");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverting a change from one commit ago.
|
||||
*/
|
||||
@Test
|
||||
void shouldRevertPreviousHistoryCommit() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_MINUS_0_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch("main");
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
assertThat(result.isSuccessful()).isTrue();
|
||||
try (GitContext context = createContext()) {
|
||||
GitDiffCommand diffCommand = new GitDiffCommand(context);
|
||||
DiffCommandRequest diffRequest = new DiffCommandRequest();
|
||||
diffRequest.setRevision(result.getRevision());
|
||||
diffRequest.setPath("kerbal");
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
diffCommand.getDiffResult(diffRequest).accept(baos);
|
||||
assertThat(baos.toString()).contains("-deathstar\n+kerbin");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertCommitOnDifferentBranch() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(DIVERGING_MAIN_LATEST_ANCESTOR);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch(DIVERGING_BRANCH);
|
||||
RevertCommandResult result = command.revert(request);
|
||||
assertThat(result.isSuccessful()).isTrue();
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
ObjectId objectId = GitUtil.getRevisionId(jRepository, result.getRevision());
|
||||
RevCommit commit = GitUtil.getCommit(jRepository, revWalk, objectId);
|
||||
assertThat(commit.getParent(0).getName()).isEqualTo(DIVERGING_BRANCH_LATEST_COMMIT);
|
||||
|
||||
GitDiffCommand diffCommand = new GitDiffCommand(context);
|
||||
DiffCommandRequest diffRequest = new DiffCommandRequest();
|
||||
|
||||
diffRequest.setRevision(result.getRevision());
|
||||
diffRequest.setAncestorChangeset(DIVERGING_BRANCH_LATEST_COMMIT);
|
||||
diffRequest.setPath("hitchhiker");
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
diffCommand.getDiffResult(diffRequest).accept(baos);
|
||||
assertThat(baos.toString()).contains("""
|
||||
-George Lucas
|
||||
+Douglas Adams"""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertTwiceOnDiffHeads() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_MINUS_0_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch("main");
|
||||
RevertCommandResult result1 = command.revert(request);
|
||||
|
||||
assertThat(result1.isSuccessful()).isTrue();
|
||||
|
||||
request.setRevision(result1.getRevision());
|
||||
RevertCommandResult result2 = command.revert(request);
|
||||
|
||||
assertThat(result2.isSuccessful()).isTrue();
|
||||
|
||||
try (GitContext context = createContext()) {
|
||||
GitDiffCommand diffCommand = new GitDiffCommand(context);
|
||||
DiffCommandRequest diffRequest = new DiffCommandRequest();
|
||||
|
||||
// Check against original head; should be the same
|
||||
diffRequest.setRevision(HEAD_REVISION);
|
||||
diffRequest.setPath("kerbal");
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
diffCommand.getDiffResult(diffRequest).accept(baos);
|
||||
// no difference, thus empty
|
||||
assertThat(baos.toString()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReportCorrectFilesAfterMergeConflict() {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(CONFLICTING_SOURCE_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch(CONFLICTING_TARGET_BRANCH);
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
assertThat(result.isSuccessful()).isFalse();
|
||||
assertThat(result.getFilesWithConflict()).containsExactly("hitchhiker");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSetCustomMessageIfGiven() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch("main");
|
||||
request.setMessage("I will never join you!");
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
assertThat(result.isSuccessful()).isTrue();
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
ObjectId objectId = GitUtil.getRevisionId(jRepository, result.getRevision());
|
||||
RevCommit commit = GitUtil.getCommit(jRepository, revWalk, objectId);
|
||||
assertThat(commit.getShortMessage()).isEqualTo("I will never join you!");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSetDefaultMessageIfNoCustomMessageGiven() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch("main");
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
|
||||
ObjectId revertedCommitId = GitUtil.getRevisionId(jRepository, request.getRevision());
|
||||
RevCommit revertedCommit = GitUtil.getCommit(jRepository, revWalk, revertedCommitId);
|
||||
ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision());
|
||||
RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId);
|
||||
|
||||
String expectedFullMessage = String.format("""
|
||||
Revert "%s"
|
||||
|
||||
This reverts commit %s.""",
|
||||
revertedCommit.getShortMessage(), revertedCommit.getName());
|
||||
|
||||
assertThat(newCommit.getShortMessage()).isEqualTo(
|
||||
"Revert \"" + revertedCommit.getShortMessage() + "\"");
|
||||
assertThat(newCommit.getFullMessage()).isEqualTo(expectedFullMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSignRevertCommit() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
|
||||
ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision());
|
||||
RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId);
|
||||
|
||||
assertThat(newCommit.getRawGpgSignature()).isNotEmpty();
|
||||
assertThat(newCommit.getRawGpgSignature()).isEqualTo(GitTestHelper.SimpleGpgSigner.getSignature());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSignNoRevertCommitIfSigningIsDisabled() throws IOException {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setSign(false);
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
|
||||
ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision());
|
||||
RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId);
|
||||
|
||||
assertThat(newCommit.getRawGpgSignature()).isNullOrEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware(value = "admin", permissions = "*:*:*")
|
||||
void shouldTakeAuthorFromSubjectIfNotSet() throws IOException {
|
||||
SimplePrincipalCollection principals = new SimplePrincipalCollection();
|
||||
principals.add("admin", "AdminRealm");
|
||||
principals.add(new User("hitchhiker", "Douglas Adams", "ga@la.xy"), "AdminRealm");
|
||||
setSubject(new Subject.Builder()
|
||||
.principals(principals)
|
||||
.authenticated(true)
|
||||
.buildSubject());
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
|
||||
RevertCommandResult result = command.revert(request);
|
||||
|
||||
assertThat(result.isSuccessful()).isTrue();
|
||||
|
||||
try (
|
||||
GitContext context = createContext();
|
||||
Repository jRepository = context.open();
|
||||
RevWalk revWalk = new RevWalk(jRepository)) {
|
||||
|
||||
ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision());
|
||||
RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId);
|
||||
|
||||
PersonIdent author = newCommit.getAuthorIdent();
|
||||
assertThat(author.getName()).isEqualTo("Douglas Adams");
|
||||
assertThat(author.getEmailAddress()).isEqualTo("ga@la.xy");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowNotFoundExceptionWhenBranchNotExist() {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(HEAD_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
request.setBranch("BogusBranch");
|
||||
assertThatThrownBy(() -> command.revert(request))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessage("could not find objectid with id BogusBranch in repository with id hitchhiker/HeartOfGold");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowNotFoundExceptionWhenRevisionNotExist() {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision("BogusRevision");
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
assertThatThrownBy(() -> command.revert(request))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessage("could not find objectid with id BogusRevision in repository with id hitchhiker/HeartOfGold");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowNoParentExceptionWhenParentNotExist() {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(PARENTLESS_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
assertThatThrownBy(() -> command.revert(request))
|
||||
.isInstanceOf(NoParentException.class)
|
||||
.hasMessage(PARENTLESS_REVISION + " has no parent.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowMultipleParentsExceptionWhenPickingMergedCommit() {
|
||||
GitRevertCommand command = createCommand();
|
||||
RevertCommandRequest request = new RevertCommandRequest();
|
||||
request.setRevision(MERGED_REVISION);
|
||||
request.setAuthor(new Person("Luke Skywalker", "luke@je.di"));
|
||||
assertThatThrownBy(() -> command.revert(request))
|
||||
.isInstanceOf(MultipleParentsNotAllowedException.class)
|
||||
.hasMessage(MERGED_REVISION + " has more than one parent changeset, which is not allowed with this request.");
|
||||
}
|
||||
|
||||
private GitRevertCommand createCommand() {
|
||||
return new GitRevertCommand(createContext("main"), repositoryManager, gitRepositoryHookEventFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,10 +162,10 @@ public class SimpleGitWorkingCopyFactoryTest extends AbstractGitCommandTestBase
|
||||
File workdir = createExistingClone(factory);
|
||||
|
||||
GitContext context = createContext();
|
||||
context.getGlobalConfig().setDefaultBranch("master");
|
||||
context.getGlobalConfig().setDefaultBranch("main");
|
||||
factory.reclaim(context, workdir, null);
|
||||
|
||||
assertBranchCheckedOutAndClean(workdir, "master");
|
||||
assertBranchCheckedOutAndClean(workdir, "main");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
You can properly zip a new repository with:
|
||||
|
||||
```
|
||||
ZIP_NAME=your name
|
||||
(cd scm-git-${ZIP_NAME}-test && zip -r ../scm-git-${ZIP_NAME}-test.zip .)
|
||||
```
|
||||
Binary file not shown.
@@ -29,7 +29,6 @@ import org.junit.Before;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import sonia.scm.repository.InitialRepositoryLocationResolver;
|
||||
import sonia.scm.repository.RepositoryLocationResolver;
|
||||
import sonia.scm.util.IOUtil;
|
||||
import sonia.scm.util.MockUtil;
|
||||
@@ -39,7 +38,6 @@ import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static java.util.Collections.emptySet;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
|
||||
@@ -62,7 +60,6 @@ public class AbstractTestBase
|
||||
UUID.randomUUID().toString());
|
||||
assertTrue(tempDirectory.mkdirs());
|
||||
contextProvider = MockUtil.getSCMContextProvider(tempDirectory);
|
||||
InitialRepositoryLocationResolver initialRepoLocationResolver = new InitialRepositoryLocationResolver(emptySet());
|
||||
repositoryLocationResolver = new TempDirRepositoryLocationResolver(tempDirectory);
|
||||
postSetUp();
|
||||
}
|
||||
|
||||
@@ -18,118 +18,66 @@ package sonia.scm.repository.spi;
|
||||
|
||||
|
||||
import com.google.common.io.Resources;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import sonia.scm.AbstractTestBase;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryTestData;
|
||||
import sonia.scm.util.IOUtil;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import java.net.URL;
|
||||
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public abstract class ZippedRepositoryTestBase extends AbstractTestBase
|
||||
{
|
||||
|
||||
public abstract class ZippedRepositoryTestBase extends AbstractTestBase {
|
||||
/**
|
||||
* This folder is used in <b>JUnit 4</b>-based tests.
|
||||
*/
|
||||
@Rule
|
||||
public TemporaryFolder tempFolder = new TemporaryFolder();
|
||||
|
||||
/**
|
||||
* This folder is used in <b>JUnit 5</b>-based tests.
|
||||
*/
|
||||
@TempDir
|
||||
public File tempDir;
|
||||
|
||||
protected Repository repository = createRepository();
|
||||
|
||||
protected File repositoryDirectory;
|
||||
|
||||
protected abstract String getType();
|
||||
|
||||
|
||||
protected abstract String getZippedRepositoryResource();
|
||||
|
||||
|
||||
@Before
|
||||
public void before()
|
||||
{
|
||||
repositoryDirectory = createRepositoryDirectory();
|
||||
}
|
||||
|
||||
|
||||
protected void checkDate(long date)
|
||||
{
|
||||
assertNotNull(date);
|
||||
assertTrue("Date should not be older than current date",
|
||||
date < System.currentTimeMillis());
|
||||
}
|
||||
|
||||
|
||||
protected Repository createRepository()
|
||||
{
|
||||
return RepositoryTestData.createHeartOfGold(getType());
|
||||
}
|
||||
|
||||
|
||||
protected File createRepositoryDirectory()
|
||||
{
|
||||
File folder = null;
|
||||
|
||||
try
|
||||
{
|
||||
folder = tempFolder.newFolder();
|
||||
folder.mkdirs();
|
||||
extract(folder);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
fail(ex.getMessage());
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
|
||||
private void extract(File folder) throws IOException
|
||||
{
|
||||
String zippedRepositoryResource = getZippedRepositoryResource();
|
||||
extract(folder, zippedRepositoryResource);
|
||||
}
|
||||
|
||||
public static void extract(File targetFolder, String zippedRepositoryResource) throws IOException {
|
||||
URL url = Resources.getResource(zippedRepositoryResource);
|
||||
|
||||
try (ZipInputStream zip = new ZipInputStream(url.openStream());)
|
||||
{
|
||||
try (ZipInputStream zip = new ZipInputStream(url.openStream())) {
|
||||
ZipEntry entry = zip.getNextEntry();
|
||||
|
||||
while (entry != null)
|
||||
{
|
||||
while (entry != null) {
|
||||
File file = new File(targetFolder, entry.getName());
|
||||
File parent = file.getParentFile();
|
||||
if (!IOUtil.isChild(parent, file)) {
|
||||
throw new IOException("invalid zip entry name");
|
||||
}
|
||||
|
||||
if (!parent.exists())
|
||||
{
|
||||
if (!parent.exists()) {
|
||||
parent.mkdirs();
|
||||
}
|
||||
|
||||
if (entry.isDirectory())
|
||||
{
|
||||
if (entry.isDirectory()) {
|
||||
file.mkdirs();
|
||||
}
|
||||
else
|
||||
{
|
||||
try (OutputStream output = new FileOutputStream(file))
|
||||
{
|
||||
} else {
|
||||
try (OutputStream output = new FileOutputStream(file)) {
|
||||
IOUtil.copy(zip, output);
|
||||
}
|
||||
}
|
||||
@@ -140,4 +88,63 @@ public abstract class ZippedRepositoryTestBase extends AbstractTestBase
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract String getType();
|
||||
|
||||
protected abstract String getZippedRepositoryResource();
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
repositoryDirectory = createRepositoryDirectory();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
public void beforeEach() {
|
||||
repositoryDirectory = createJUnit5RepositoryDirectory();
|
||||
}
|
||||
|
||||
@SuppressWarnings("java:S5960") // no production code
|
||||
protected void checkDate(long date) {
|
||||
assertTrue("Date should not be older than current date",
|
||||
date < System.currentTimeMillis());
|
||||
}
|
||||
|
||||
protected Repository createRepository() {
|
||||
return RepositoryTestData.createHeartOfGold(getType());
|
||||
}
|
||||
|
||||
protected File createRepositoryDirectory() {
|
||||
File folder = null;
|
||||
|
||||
try {
|
||||
folder = tempFolder.newFolder();
|
||||
folder.mkdirs();
|
||||
extract(folder);
|
||||
} catch (IOException ex) {
|
||||
fail(ex.getMessage());
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
protected File createJUnit5RepositoryDirectory() {
|
||||
File folder = null;
|
||||
|
||||
try {
|
||||
folder = tempDir;
|
||||
if (!folder.isDirectory()) {
|
||||
fail("Temporary JUnit 5 folder not created");
|
||||
}
|
||||
extract(folder);
|
||||
} catch (IOException ex) {
|
||||
fail(ex.getMessage());
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
private void extract(File folder) throws IOException {
|
||||
String zippedRepositoryResource = getZippedRepositoryResource();
|
||||
extract(folder, zippedRepositoryResource);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import sonia.scm.AlreadyExistsException;
|
||||
import sonia.scm.BadRequestException;
|
||||
import sonia.scm.BranchAlreadyExistsException;
|
||||
import sonia.scm.ConcurrentModificationException;
|
||||
import sonia.scm.ConflictException;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.ScmConstraintViolationException;
|
||||
|
||||
@@ -83,6 +84,7 @@ public class RestDispatcher {
|
||||
registerException(AlreadyExistsException.class, Status.CONFLICT);
|
||||
registerException(BranchAlreadyExistsException.class, Status.CONFLICT);
|
||||
registerException(ConcurrentModificationException.class, Status.CONFLICT);
|
||||
registerException(ConflictException.class, Status.CONFLICT);
|
||||
registerException(UnauthorizedException.class, Status.FORBIDDEN);
|
||||
registerException(AuthorizationException.class, Status.FORBIDDEN);
|
||||
registerException(AuthenticationException.class, Status.UNAUTHORIZED);
|
||||
|
||||
@@ -33,6 +33,7 @@ export * from "./repositories";
|
||||
export * from "./namespaces";
|
||||
export * from "./branches";
|
||||
export * from "./changesets";
|
||||
export * from "./revert";
|
||||
export * from "./tags";
|
||||
export * from "./config";
|
||||
export * from "./admin";
|
||||
|
||||
83
scm-ui/ui-api/src/revert.test.ts
Normal file
83
scm-ui/ui-api/src/revert.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { Changeset } from "@scm-manager/ui-types";
|
||||
import { renderHook } from "@testing-library/react-hooks";
|
||||
import createWrapper from "./tests/createWrapper";
|
||||
import { RevertRequest, RevertResponse, useRevert } from "./revert";
|
||||
import createInfiniteCachingClient from "./tests/createInfiniteCachingClient";
|
||||
|
||||
const queryClient = createInfiniteCachingClient();
|
||||
|
||||
const mockChangeset: Changeset = {
|
||||
id: "0f3cdd",
|
||||
description: "Awesome change",
|
||||
date: new Date(),
|
||||
author: {
|
||||
name: "Arthur Dent",
|
||||
},
|
||||
_embedded: {},
|
||||
_links: {
|
||||
revert: {
|
||||
href: "/hitchhiker/heart-of-gold/changesets/0f3cdd/revert",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expectedRevision: RevertResponse = {
|
||||
revision: "a3ffde",
|
||||
};
|
||||
|
||||
const revertRequest: RevertRequest = {
|
||||
branch: "captain/kirk",
|
||||
message: "Hello World!",
|
||||
};
|
||||
|
||||
beforeEach(() => queryClient.clear());
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
describe("useRevert tests", () => {
|
||||
const fetchRevert = async (changeset: Changeset, request: RevertRequest) => {
|
||||
fetchMock.postOnce("api/v2/hitchhiker/heart-of-gold/changesets/0f3cdd/revert", expectedRevision);
|
||||
const { result: useRevertResult, waitFor } = renderHook(() => useRevert(changeset), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
return !!useRevertResult.current.revert || !!useRevertResult.current.error;
|
||||
});
|
||||
|
||||
const { waitFor: waitForRevert } = renderHook(() => useRevertResult.current.revert(request), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
});
|
||||
|
||||
await waitForRevert(() => {
|
||||
return !!useRevertResult.current.revision || !!useRevertResult.current.error;
|
||||
});
|
||||
|
||||
return useRevertResult.current;
|
||||
};
|
||||
|
||||
it("should return revision from revert", async () => {
|
||||
const { revision, error } = await fetchRevert(mockChangeset, revertRequest);
|
||||
expect(revision).toEqual(expectedRevision);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
45
scm-ui/ui-api/src/revert.ts
Normal file
45
scm-ui/ui-api/src/revert.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import { requiredLink } from "./links";
|
||||
import { Changeset } from "@scm-manager/ui-types";
|
||||
import { apiClient } from "./apiclient";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
export type RevertRequest = {
|
||||
branch: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RevertResponse = {
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export const useRevert = (changeset: Changeset) => {
|
||||
const link = requiredLink(changeset, "revert");
|
||||
const { isLoading, error, mutate, data } = useMutation<RevertResponse, Error, RevertRequest>({
|
||||
mutationFn: async (request: RevertRequest) => {
|
||||
const response = await apiClient.post(link, request, "application/vnd.scmm-revert+json;v=2");
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
return {
|
||||
revert: mutate,
|
||||
isLoading,
|
||||
error,
|
||||
revision: data,
|
||||
};
|
||||
};
|
||||
@@ -47,6 +47,7 @@
|
||||
"@storybook/builder-webpack5": "^6.5.10",
|
||||
"@storybook/manager-webpack5": "^6.5.10",
|
||||
"@storybook/react": "^6.5.10",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"storybook-addon-i18next": "^1.3.0",
|
||||
"storybook-addon-themes": "^6.1.0",
|
||||
"@types/classnames": "^2.3.1",
|
||||
@@ -108,4 +109,4 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type Props = ButtonProps & {
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link ui-buttons/src/Button.tsx} instead
|
||||
* @deprecated Use {@link ui-core/src/base/buttons/Button} instead
|
||||
*/
|
||||
const Button = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import * as changesets from "./changesets";
|
||||
import { render } from "@testing-library/react";
|
||||
import { Branch, Changeset, Repository } from "@scm-manager/ui-types";
|
||||
import ChangesetButtonGroup from "./ChangesetButtonGroup";
|
||||
import React from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { stubI18Next } from "@scm-manager/ui-tests";
|
||||
|
||||
const createChangesetLink = jest.spyOn(changesets, "createChangesetLink");
|
||||
const createChangesetLinkByBranch = jest.spyOn(changesets, "createChangesetLinkByBranch");
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("ChangesetButtonGroup", () => {
|
||||
test("shouldCallCreateChangesetLinkWithoutBranch", async () => {
|
||||
stubI18Next();
|
||||
const { repository, changeset } = createTestData();
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ChangesetButtonGroup repository={repository} changeset={changeset}></ChangesetButtonGroup>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(createChangesetLink).toHaveBeenCalled();
|
||||
expect(createChangesetLinkByBranch).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("shouldCallCreateChangesetLinkByBranchWithBranch", async () => {
|
||||
stubI18Next();
|
||||
const { repository, changeset, branch } = createTestData();
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ChangesetButtonGroup repository={repository} changeset={changeset} branch={branch}></ChangesetButtonGroup>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(createChangesetLinkByBranch).toHaveBeenCalled();
|
||||
expect(createChangesetLink).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO centralized test data
|
||||
function createTestData() {
|
||||
const repository: Repository = { _links: {}, name: "", namespace: "", type: "" };
|
||||
const changeset: Changeset = {
|
||||
_links: {},
|
||||
author: {
|
||||
name: "",
|
||||
},
|
||||
date: new Date(),
|
||||
description: "",
|
||||
id: "",
|
||||
};
|
||||
const branch: Branch = { _links: {}, name: "", revision: "" };
|
||||
return { repository, changeset, branch };
|
||||
}
|
||||
@@ -15,21 +15,24 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import { ButtonAddons, Button } from "../../buttons";
|
||||
import { createChangesetLink, createSourcesLink } from "./changesets";
|
||||
import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import { Button, ButtonAddons } from "../../buttons";
|
||||
import { createChangesetLink, createChangesetLinkByBranch, createSourcesLink } from "./changesets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
changeset: Changeset;
|
||||
file?: File;
|
||||
branch?: Branch;
|
||||
};
|
||||
|
||||
const ChangesetButtonGroup = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>(
|
||||
({ repository, changeset, file }, ref) => {
|
||||
({ repository, changeset, file, branch }, ref) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const changesetLink = createChangesetLink(repository, changeset);
|
||||
const changesetLink = branch
|
||||
? createChangesetLinkByBranch(repository, changeset, branch)
|
||||
: createChangesetLink(repository, changeset);
|
||||
const sourcesLink = createSourcesLink(repository, changeset, file);
|
||||
return (
|
||||
<ButtonAddons className="m-0">
|
||||
|
||||
@@ -16,20 +16,23 @@
|
||||
|
||||
import ChangesetRow from "./ChangesetRow";
|
||||
import React, { FC } from "react";
|
||||
import { Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import { KeyboardIterator } from "@scm-manager/ui-shortcuts";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
changesets: Changeset[];
|
||||
file?: File;
|
||||
branch?: Branch;
|
||||
};
|
||||
|
||||
const ChangesetList: FC<Props> = ({ repository, changesets, file }) => {
|
||||
const ChangesetList: FC<Props> = ({ repository, changesets, file, branch }) => {
|
||||
return (
|
||||
<KeyboardIterator>
|
||||
{changesets.map((changeset) => {
|
||||
return <ChangesetRow key={changeset.id} repository={repository} changeset={changeset} file={file} />;
|
||||
return (
|
||||
<ChangesetRow key={changeset.id} repository={repository} changeset={changeset} file={file} branch={branch} />
|
||||
);
|
||||
})}
|
||||
</KeyboardIterator>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import React, { FC } from "react";
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
|
||||
import { Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import ChangesetButtonGroup from "./ChangesetButtonGroup";
|
||||
import SingleChangeset from "./SingleChangeset";
|
||||
import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts";
|
||||
@@ -27,11 +27,13 @@ type Props = {
|
||||
repository: Repository;
|
||||
changeset: Changeset;
|
||||
file?: File;
|
||||
branch?: Branch;
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
// & references parent rule
|
||||
// have a look at https://cssinjs.org/jss-plugin-nested?v=v10.0.0-alpha.9
|
||||
|
||||
& + & {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
@@ -39,7 +41,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const ChangesetRow: FC<Props> = ({ repository, changeset, file }) => {
|
||||
const ChangesetRow: FC<Props> = ({ repository, changeset, file, branch }) => {
|
||||
const ref = useKeyboardIteratorTarget();
|
||||
return (
|
||||
<Wrapper>
|
||||
@@ -48,7 +50,7 @@ const ChangesetRow: FC<Props> = ({ repository, changeset, file }) => {
|
||||
<SingleChangeset repository={repository} changeset={changeset} />
|
||||
</div>
|
||||
<div className={classNames("column", "is-flex", "is-justify-content-flex-end", "is-align-items-center")}>
|
||||
<ChangesetButtonGroup ref={ref} repository={repository} changeset={changeset} file={file} />
|
||||
<ChangesetButtonGroup ref={ref} repository={repository} changeset={changeset} file={file} branch={branch} />
|
||||
<ExtensionPoint<extensionPoints.ChangesetRight>
|
||||
name="changeset.right"
|
||||
props={{
|
||||
|
||||
@@ -14,9 +14,38 @@
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import { parseDescription } from "./changesets";
|
||||
import { createChangesetLink, createChangesetLinkByBranch, parseDescription } from "./changesets";
|
||||
import { Branch, Changeset, Repository } from "@scm-manager/ui-types";
|
||||
|
||||
describe("parseDescription tests", () => {
|
||||
describe("createChangesetLink", () => {
|
||||
it("should return a changeset link", () => {
|
||||
const { repository, changeset } = createTestData();
|
||||
const link = createChangesetLink(repository, changeset);
|
||||
expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChangesetLinkByBranch", () => {
|
||||
it("should return a changeset link with a branch query with given branch", () => {
|
||||
const { repository, changeset, branch } = createTestData();
|
||||
const link = createChangesetLinkByBranch(repository, changeset, branch);
|
||||
expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c?branch=resonanceCascade");
|
||||
});
|
||||
it("should return no branch query parameter with empty string", () => {
|
||||
const { repository, changeset, branch } = createTestData();
|
||||
branch.name = "";
|
||||
const link = createChangesetLinkByBranch(repository, changeset, branch);
|
||||
expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c");
|
||||
});
|
||||
it("should escape a branch with a slash inside", () => {
|
||||
const { repository, changeset, branch } = createTestData();
|
||||
branch.name = "feature/rescueWorld";
|
||||
const link = createChangesetLinkByBranch(repository, changeset, branch);
|
||||
expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c?branch=feature%2FrescueWorld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDescription", () => {
|
||||
it("should return a description with title and message", () => {
|
||||
const desc = parseDescription("Hello\nTrillian");
|
||||
expect(desc.title).toBe("Hello");
|
||||
@@ -34,3 +63,34 @@ describe("parseDescription tests", () => {
|
||||
expect(desc.message).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
function createTestData() {
|
||||
const repository: Repository = {
|
||||
name: "anarchy",
|
||||
namespace: "sandbox",
|
||||
type: "git",
|
||||
_links: {},
|
||||
};
|
||||
|
||||
const changeset: Changeset = {
|
||||
author: {
|
||||
name: "Gordon Freeman",
|
||||
},
|
||||
date: new Date(),
|
||||
description: "Some repository.",
|
||||
id: "4f153aa670d4b27c",
|
||||
_links: {},
|
||||
};
|
||||
|
||||
const branch: Branch = {
|
||||
name: "resonanceCascade",
|
||||
revision: "4f153aa670d4b27c",
|
||||
_links: {},
|
||||
};
|
||||
|
||||
return {
|
||||
repository,
|
||||
changeset,
|
||||
branch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import { Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types";
|
||||
|
||||
export type Description = {
|
||||
title: string;
|
||||
@@ -25,6 +25,16 @@ export function createChangesetLink(repository: Repository, changeset: Changeset
|
||||
return `/repo/${repository.namespace}/${repository.name}/code/changeset/${changeset.id}`;
|
||||
}
|
||||
|
||||
export function createChangesetLinkByBranch(repository: Repository, changeset: Changeset, branch: Branch) {
|
||||
if (!branch.name) {
|
||||
return `/repo/${repository.namespace}/${repository.name}/code/changeset/${changeset.id}`;
|
||||
} else {
|
||||
return `/repo/${repository.namespace}/${repository.name}/code/changeset/${changeset.id}?branch=${encodeURIComponent(
|
||||
branch.name
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSourcesLink(repository: Repository, changeset: Changeset, file?: File) {
|
||||
let url = `/repo/${repository.namespace}/${repository.name}/code/sources/${changeset.id}`;
|
||||
|
||||
@@ -50,6 +60,6 @@ export function parseDescription(description?: string): Description {
|
||||
|
||||
return {
|
||||
title,
|
||||
message
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ import { createMount, createShallow } from "enzyme-context";
|
||||
import { routerContext } from "enzyme-context-react-router-4";
|
||||
|
||||
const plugins = {
|
||||
history: routerContext()
|
||||
history: routerContext(),
|
||||
};
|
||||
|
||||
// TODO Enzyme is not going to be supported in React 19+.: https://testing-library.com/docs/react-testing-library/migrate-from-enzyme/
|
||||
export const mount = createMount(plugins);
|
||||
export const shallow = createShallow(plugins);
|
||||
|
||||
@@ -14,16 +14,32 @@
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
export const jestMock = jest.mock("react-i18next", () => ({
|
||||
// this mock makes sure any components using the translate HoC receive the t function as a prop
|
||||
withTranslation: () => (Component: any) => {
|
||||
Component.defaultProps = {
|
||||
...Component.defaultProps,
|
||||
t: (key: string) => key
|
||||
};
|
||||
return Component;
|
||||
},
|
||||
useTranslation: (ns: string) => {
|
||||
return [(key: string) => key];
|
||||
}
|
||||
}));
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
|
||||
/**
|
||||
* This provides a minimum i18next scaffold during initialization of a unit test.
|
||||
*
|
||||
* It does not connect to the i18next information used in production,
|
||||
* but avoids warnings emerging due to i18next being uninitialized.
|
||||
*
|
||||
* More information: <a href="https://react.i18next.com/misc/testing">https://react.i18next.com/misc/testing</a>
|
||||
*/
|
||||
export function stubI18Next() {
|
||||
// TODO should be changed to async/await
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: "de",
|
||||
fallbackLng: "en",
|
||||
|
||||
ns: ["translationsNS"],
|
||||
defaultNS: "translationsNS",
|
||||
|
||||
debug: false,
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
resources: { en: { translationsNS: {} } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-context": "^1.1.2",
|
||||
"enzyme-context-react-router-4": "^2.0.0",
|
||||
"raf": "^3.4.1"
|
||||
"i18next": "21",
|
||||
"raf": "^3.4.1",
|
||||
"react-i18next": "11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@scm-manager/tsconfig": "^2.13.0",
|
||||
@@ -25,4 +27,4 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,18 @@
|
||||
"tag": {
|
||||
"create": "Tag erstellen"
|
||||
},
|
||||
"revert": {
|
||||
"button": "Revert",
|
||||
"modal": {
|
||||
"title": "Changeset zurücksetzen",
|
||||
"description": "Sie wenden einen Revert an von Commit {{commit}} auf",
|
||||
"branch": "Branch",
|
||||
"commitMessage": "Commit-Nachricht",
|
||||
"commitMessagePlaceholder": "Revert \"{{description}}\"\n\nThis reverts commit {{id}}.",
|
||||
"submit": "Revert",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"containedInTags": {
|
||||
"containedInTag_one": "Enthalten in {{count}} Tag",
|
||||
"containedInTag_other": "Enthalten in {{count}} Tags",
|
||||
|
||||
@@ -347,6 +347,18 @@
|
||||
"tag": {
|
||||
"create": "Create Tag"
|
||||
},
|
||||
"revert": {
|
||||
"button": "Revert",
|
||||
"modal": {
|
||||
"title": "Revert Changeset",
|
||||
"description": "You are going to apply a revert for commit {{commit}} on",
|
||||
"branch": "Branch",
|
||||
"commitMessage": "Commit Message",
|
||||
"commitMessagePlaceholder": "Revert \"{{description}}\"\n\nThis reverts commit {{id}}.",
|
||||
"submit": "Revert",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"containedInTags": {
|
||||
"containedInTag_one": "Contained in {{count}} tag",
|
||||
"containedInTag_other": "Contained in {{count}} tags",
|
||||
|
||||
@@ -15,28 +15,30 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import "@scm-manager/ui-tests";
|
||||
import EditGroupNavLink from "./EditGroupNavLink";
|
||||
import { mount } from "@scm-manager/ui-tests";
|
||||
|
||||
it("should render nothing, if the edit link is missing", () => {
|
||||
const group = {
|
||||
_links: {}
|
||||
};
|
||||
describe("EditGroupNavLink tests", () => {
|
||||
it("should render nothing, if the edit link is missing", () => {
|
||||
const group = {
|
||||
_links: {},
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditGroupNavLink group={group} editUrl="/group/edit" />);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/groups"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditGroupNavLink group={group} editUrl="/group/edit" />);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
const navLink = mount(<EditGroupNavLink group={group} editUrl="/group/edit" />);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/groups",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const navLink = mount(<EditGroupNavLink group={group} editUrl="/group/edit" />);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,17 +15,21 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mount, shallow } from "@scm-manager/ui-tests";
|
||||
import { mount } from "@scm-manager/ui-tests";
|
||||
import "@scm-manager/ui-tests";
|
||||
import PermissionsNavLink from "./PermissionsNavLink";
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("PermissionsNavLink", () => {
|
||||
it("should render nothing, if the modify link is missing", () => {
|
||||
const repository = {
|
||||
_links: {},
|
||||
};
|
||||
|
||||
const navLink = shallow(<PermissionsNavLink repository={repository} permissionUrl="" />);
|
||||
const navLink = mount(<PermissionsNavLink repository={repository} permissionUrl="" />);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -33,12 +33,13 @@ import {
|
||||
FileControlFactory,
|
||||
SignatureIcon,
|
||||
} from "@scm-manager/ui-components";
|
||||
import { Tooltip, SubSubtitle } from "@scm-manager/ui-core";
|
||||
import { SubSubtitle } from "@scm-manager/ui-core";
|
||||
import { Button, Icon } from "@scm-manager/ui-buttons";
|
||||
import ContributorTable from "./ContributorTable";
|
||||
import { Link, Link as ReactLink } from "react-router-dom";
|
||||
import CreateTagModal from "./CreateTagModal";
|
||||
import { useContainedInTags } from "@scm-manager/ui-api";
|
||||
import RevertModal from "./RevertModal";
|
||||
|
||||
type Props = {
|
||||
changeset: Changeset;
|
||||
@@ -72,36 +73,56 @@ const SeparatedParents = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
|
||||
const Contributors: FC<{ changeset: Changeset; repository: Repository }> = ({ changeset, repository }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tagCreationModalVisible, setTagCreationModalVisible] = useState(false);
|
||||
const signatureIcon =
|
||||
changeset?.signatures && changeset.signatures.length > 0 ? (
|
||||
<SignatureIcon className="mx-2" signatures={changeset.signatures} />
|
||||
) : (
|
||||
<> </>
|
||||
);
|
||||
const showCreateTagButton = "tag" in changeset._links;
|
||||
return (
|
||||
<details className="mb-2" onClick={() => setOpen(!open)}>
|
||||
<summary className="is-flex is-flex-direction-row is-clickable" aria-label={t("changeset.contributors.list")}>
|
||||
{open ? (
|
||||
<>
|
||||
<Icon alt={t("changeset.contributors.hideList")}>angle-down</Icon>
|
||||
<span>{t("changeset.contributors.list")}</span> {signatureIcon}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon alt={t("changeset.contributors.showList")}>angle-right</Icon>{" "}
|
||||
<span>
|
||||
<ChangesetAuthor changeset={changeset} />
|
||||
</span>
|
||||
<span>{signatureIcon}</span>{" "}
|
||||
<span>{t("changeset.contributors.count", { count: countContributors(changeset) })}</span>
|
||||
</>
|
||||
<div className="is-flex is-flex-wrap-wrap">
|
||||
<details className="mb-2" onClick={() => setOpen(!open)}>
|
||||
<summary className="is-flex is-flex-direction-row is-clickable" aria-label={t("changeset.contributors.list")}>
|
||||
{open ? (
|
||||
<>
|
||||
<Icon alt={t("changeset.contributors.hideList")}>angle-down</Icon>
|
||||
<span>{t("changeset.contributors.list")}</span> {signatureIcon}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon alt={t("changeset.contributors.showList")}>angle-right</Icon>{" "}
|
||||
<span>
|
||||
<ChangesetAuthor changeset={changeset} />
|
||||
</span>
|
||||
<span>{signatureIcon}</span>{" "}
|
||||
<span>{t("changeset.contributors.count", { count: countContributors(changeset) })}</span>
|
||||
</>
|
||||
)}
|
||||
</summary>
|
||||
<ContributorTable changeset={changeset} />
|
||||
</details>
|
||||
<div className="is-flex has-gap-2 ml-auto">
|
||||
<ChangesetTags changeset={changeset} />
|
||||
{showCreateTagButton && (
|
||||
<Button className="tag is-success has-gap-1" onClick={() => setTagCreationModalVisible(true)}>
|
||||
<Icon>plus</Icon>
|
||||
{(changeset._embedded?.tags?.length === 0 && t("changeset.tag.create")) || ""}
|
||||
</Button>
|
||||
)}
|
||||
</summary>
|
||||
<ContributorTable changeset={changeset} />
|
||||
</details>
|
||||
</div>
|
||||
{tagCreationModalVisible && (
|
||||
<CreateTagModal
|
||||
repository={repository}
|
||||
changeset={changeset}
|
||||
onClose={() => setTagCreationModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -142,7 +163,7 @@ const ContainedInTags: FC<{ changeset: Changeset; repository: Repository }> = ({
|
||||
};
|
||||
|
||||
const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory }) => {
|
||||
const [isTagCreationModalVisible, setTagCreationModalVisible] = useState(false);
|
||||
const [revertModalVisible, setRevertModalVisible] = useState(false);
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
const description = changesets.parseDescription(changeset.description);
|
||||
@@ -153,7 +174,7 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
|
||||
{parent.id.substring(0, 7)}
|
||||
</ReactLink>
|
||||
));
|
||||
const showCreateButton = "tag" in changeset._links;
|
||||
const showRevertButton = "revert" in changeset._links;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -176,10 +197,10 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
|
||||
<AvatarImage person={changeset.author} />
|
||||
</p>
|
||||
</AvatarWrapper>
|
||||
<div className="media-content">
|
||||
<Contributors changeset={changeset} />
|
||||
<div className="media-content pr-2 pt-2 ">
|
||||
<Contributors changeset={changeset} repository={repository} />
|
||||
<ContainedInTags changeset={changeset} repository={repository} />
|
||||
<div className="is-flex is-ellipsis-overflow">
|
||||
<div className="is-flex is-flex-wrap-wrap is-ellipsis-overflow">
|
||||
<p>
|
||||
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />
|
||||
</p>
|
||||
@@ -189,28 +210,19 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
|
||||
{parents}
|
||||
</SeparatedParents>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="media-right">
|
||||
<ChangesetTags changeset={changeset} />
|
||||
</div>
|
||||
|
||||
{showCreateButton && (
|
||||
<div className="media-right">
|
||||
<Tooltip message={t("changeset.tag.create")}>
|
||||
<Button className="tag is-success has-gap-1" onClick={() => setTagCreationModalVisible(true)}>
|
||||
<Icon>plus</Icon>
|
||||
{(changeset._embedded?.tags?.length === 0 && t("changeset.tag.create")) || ""}
|
||||
{showRevertButton && (
|
||||
<Button
|
||||
className="tag ml-auto mr-2 mt-2"
|
||||
variant="tertiary"
|
||||
onClick={() => setRevertModalVisible(true)}
|
||||
>
|
||||
{t("changeset.revert.button")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isTagCreationModalVisible && (
|
||||
<CreateTagModal
|
||||
repository={repository}
|
||||
changeset={changeset}
|
||||
onClose={() => setTagCreationModalVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
{revertModalVisible && (
|
||||
<RevertModal repository={repository} changeset={changeset} onClose={() => setRevertModalVisible(false)} />
|
||||
)}
|
||||
</article>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import { getSelectedBranch } from "./RevertModal";
|
||||
|
||||
describe("getSelectedBranch", () => {
|
||||
it("should return the correct branch from a query", () => {
|
||||
const output = getSelectedBranch({ branch: "scotty" });
|
||||
expect(output).toBe("scotty");
|
||||
});
|
||||
it("should return an empty string if given no branch query", () => {
|
||||
const output = getSelectedBranch({});
|
||||
expect(output).toBe("");
|
||||
});
|
||||
// slash escaping is observed to happen before, so it isn't tested here.
|
||||
});
|
||||
140
scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.tsx
Normal file
140
scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import queryString from "query-string";
|
||||
import { useBranches, useRevert } from "@scm-manager/ui-api";
|
||||
import { Select, Textarea } from "@scm-manager/ui-forms";
|
||||
import { Modal } from "@scm-manager/ui-components";
|
||||
import { Button, ErrorNotification, Label, Loading, RequiredMarker } from "@scm-manager/ui-core";
|
||||
import { Changeset, Repository } from "@scm-manager/ui-types";
|
||||
|
||||
type Props = {
|
||||
changeset: Changeset;
|
||||
repository: Repository;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const RevertModal: FC<Props> = ({ repository, changeset, onClose }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const history = useHistory();
|
||||
const { isLoading: isBranchesLoading, error: branchesError, data: branchData } = useBranches(repository);
|
||||
const { revert, isLoading: isRevertLoading, error: revertError } = useRevert(changeset);
|
||||
const ref = useRef<HTMLSelectElement | null>(null);
|
||||
const queryParams = queryString.parse(window.location.search);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>(getSelectedBranch(queryParams));
|
||||
const [textareaValue, setTextareaValue] = useState<string>(
|
||||
changeset?.description.length > 0
|
||||
? t("changeset.revert.modal.commitMessagePlaceholder", {
|
||||
description: changeset.description.split("\n")[0],
|
||||
id: changeset.id,
|
||||
})
|
||||
: ""
|
||||
);
|
||||
|
||||
const mappedBranches = [
|
||||
{ label: "", value: "", hidden: true },
|
||||
...(branchData?._embedded?.branches?.map((branch) => ({
|
||||
label: branch.name,
|
||||
value: branch.name,
|
||||
})) || []),
|
||||
];
|
||||
|
||||
const handleSelectChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedBranch(event.target.value);
|
||||
};
|
||||
|
||||
const handleTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setTextareaValue(event.target.value);
|
||||
};
|
||||
|
||||
let body;
|
||||
if (isRevertLoading) {
|
||||
body = <Loading />;
|
||||
} else if (revertError) {
|
||||
body = <ErrorNotification error={revertError} />;
|
||||
} else if (branchesError) {
|
||||
body = <ErrorNotification error={branchesError} />;
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("changeset.revert.modal.description", {
|
||||
commit: changeset.id.substring(0, 7),
|
||||
})}
|
||||
</p>
|
||||
<Label className="is-flex is-align-items-baseline">
|
||||
<span className="mr-2">{t("changeset.revert.modal.branch")}</span>
|
||||
<Select options={mappedBranches} ref={ref} onChange={handleSelectChange} defaultValue={selectedBranch} />
|
||||
</Label>
|
||||
</div>
|
||||
<br />
|
||||
<Label>
|
||||
{t("changeset.revert.modal.commitMessage")}
|
||||
<RequiredMarker />
|
||||
<Textarea value={textareaValue} onChange={handleTextareaChange} />
|
||||
</Label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("changeset.revert.modal.title")}
|
||||
active={true}
|
||||
body={body}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>{t("changeset.revert.modal.cancel")}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
revert(
|
||||
{
|
||||
branch: selectedBranch,
|
||||
message: textareaValue,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
onClose();
|
||||
history.push(
|
||||
`/repo/${repository.namespace}/${repository.name}/code/changeset/${response.revision}`
|
||||
);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
isLoading={isRevertLoading || isBranchesLoading}
|
||||
disabled={!selectedBranch || !textareaValue}
|
||||
>
|
||||
{t("changeset.revert.modal.submit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
closeFunction={onClose}
|
||||
initialFocusRef={ref}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function getSelectedBranch(queryParams: queryString.ParsedQuery) {
|
||||
return queryParams?.branch ? (queryParams.branch as string) : "";
|
||||
}
|
||||
|
||||
export default RevertModal;
|
||||
@@ -19,15 +19,8 @@ import { Redirect, useRouteMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChangesets } from "@scm-manager/ui-api";
|
||||
import { Branch, ChangesetCollection, Repository } from "@scm-manager/ui-types";
|
||||
import {
|
||||
ChangesetList,
|
||||
ErrorNotification,
|
||||
LinkPaginator,
|
||||
Loading,
|
||||
Notification,
|
||||
urls,
|
||||
} from "@scm-manager/ui-components";
|
||||
import { useDocumentTitle } from "@scm-manager/ui-core";
|
||||
import { ChangesetList, LinkPaginator, urls } from "@scm-manager/ui-components";
|
||||
import { ErrorNotification, Notification, Loading, useDocumentTitle } from "@scm-manager/ui-core";
|
||||
|
||||
export const usePage = () => {
|
||||
const match = useRouteMatch();
|
||||
@@ -120,7 +113,7 @@ export const ChangesetsPanel: FC<ChangesetsPanelProps> = ({ repository, error, i
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-block">
|
||||
<ChangesetList repository={repository} changesets={changesets} />
|
||||
<ChangesetList repository={repository} changesets={changesets} branch={branch} />
|
||||
</div>
|
||||
<div className="panel-footer">
|
||||
<LinkPaginator collection={data} page={page} />
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { shallow } from "@scm-manager/ui-tests";
|
||||
import { mount, shallow } from "@scm-manager/ui-tests";
|
||||
import "@scm-manager/ui-tests";
|
||||
import EditUserNavLink from "./EditUserNavLink";
|
||||
|
||||
it("should render nothing, if the edit link is missing", () => {
|
||||
const user = {
|
||||
_links: {}
|
||||
_links: {},
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditUserNavLink user={user} editUrl="/user/edit" />);
|
||||
const navLink = mount(<EditUserNavLink user={user} editUrl="/user/edit" />);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
@@ -32,9 +32,9 @@ it("should render the navLink", () => {
|
||||
const user = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/users"
|
||||
}
|
||||
}
|
||||
href: "/users",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditUserNavLink user={user} editUrl="/user/edit" />);
|
||||
|
||||
@@ -21,14 +21,18 @@ import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import sonia.scm.ConflictException;
|
||||
import sonia.scm.PageResult;
|
||||
import sonia.scm.repository.Changeset;
|
||||
import sonia.scm.repository.ChangesetPagingResult;
|
||||
@@ -37,9 +41,12 @@ import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryPermissions;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.repository.api.RevertCommandBuilder;
|
||||
import sonia.scm.repository.api.RevertCommandResult;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
import static sonia.scm.NotFoundException.notFound;
|
||||
@@ -54,11 +61,14 @@ public class ChangesetRootResource {
|
||||
|
||||
private final ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper;
|
||||
|
||||
private final ResourceLinks resourceLinks;
|
||||
|
||||
@Inject
|
||||
public ChangesetRootResource(RepositoryServiceFactory serviceFactory, ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper, ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper) {
|
||||
ChangesetRootResource(RepositoryServiceFactory serviceFactory, ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper, ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper, ResourceLinks resourceLinks) {
|
||||
this.serviceFactory = serviceFactory;
|
||||
this.changesetCollectionToDtoMapper = changesetCollectionToDtoMapper;
|
||||
this.changesetToChangesetDtoMapper = changesetToChangesetDtoMapper;
|
||||
this.resourceLinks = resourceLinks;
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -151,4 +161,80 @@ public class ChangesetRootResource {
|
||||
return changesetToChangesetDtoMapper.map(changeset, repository);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/revert")
|
||||
@Operation(summary = "Revert changeset", description = "Reverts the changes of a single changeset.", tags = "Repository")
|
||||
@Consumes(VndMediaType.REVERT)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@ApiResponse(
|
||||
responseCode = "201",
|
||||
description = "success",
|
||||
content = @Content(
|
||||
mediaType = "text/plain",
|
||||
schema = @Schema(implementation = ChangesetDto.class)
|
||||
)
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "bad request, no parent for the changeset available or multiple parents",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to revert the changeset (push)")
|
||||
@ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "not found, no changeset with the specified id is available in the repository, the branch or the repository itself does not exist",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
@ApiResponse(
|
||||
responseCode = "409",
|
||||
description = "conflict, the revert could not be performed automatically because of conflicts in the changes",
|
||||
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 revert(@PathParam("namespace") String namespace,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("id") String id,
|
||||
RevertDto revertDto) {
|
||||
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
|
||||
RepositoryPermissions.push(repositoryService.getRepository()).check();
|
||||
RevertCommandBuilder command = repositoryService.getRevertCommand()
|
||||
.setRevision(id);
|
||||
command.setBranch(revertDto.branch());
|
||||
command.setMessage(revertDto.message());
|
||||
|
||||
RevertCommandResult result = command.execute();
|
||||
|
||||
if (result.isSuccessful()) {
|
||||
return Response.
|
||||
created(URI.create(resourceLinks.changeset().changeset(namespace, name, result.getRevision())))
|
||||
.entity(new RevertResponseDto(result.getRevision()))
|
||||
.build();
|
||||
} else {
|
||||
throw new ConflictException(
|
||||
new NamespaceAndName(namespace, name),
|
||||
result.getFilesWithConflict()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record RevertDto(String branch, String message) {
|
||||
}
|
||||
|
||||
public record RevertResponseDto(String revision) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import sonia.scm.ConflictException;
|
||||
import sonia.scm.api.rest.ContextualExceptionMapper;
|
||||
|
||||
@Provider
|
||||
public class ConflictExceptionMapper extends ContextualExceptionMapper<ConflictException> {
|
||||
|
||||
@Inject
|
||||
public ConflictExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) {
|
||||
super(ConflictException.class, Response.Status.CONFLICT, mapper);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import de.otto.edison.hal.Embedded;
|
||||
import de.otto.edison.hal.Links;
|
||||
import jakarta.inject.Inject;
|
||||
@@ -128,6 +127,12 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
|
||||
if (repositoryService.isSupported(Command.DIFF_RESULT)) {
|
||||
linksBuilder.single(link("diffParsed", resourceLinks.diff().parsed(namespace, name, source.getId())));
|
||||
}
|
||||
if (
|
||||
repositoryService.isSupported(Command.REVERT) &&
|
||||
RepositoryPermissions.push(repository).isPermitted() &&
|
||||
source.getParents().size() == 1) {
|
||||
linksBuilder.single(link("revert", resourceLinks.changeset().revert(namespace, name, source.getId())));
|
||||
}
|
||||
}
|
||||
embeddedBuilder.with("parents", getListOfObjects(source.getParents(), parent -> changesetToParentDtoMapper.map(new Changeset(parent, 0L, null), repository)));
|
||||
|
||||
|
||||
@@ -736,6 +736,10 @@ class ResourceLinks {
|
||||
public String changeset(String namespace, String name, String revision) {
|
||||
return changesetLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("changesets").parameters().method("get").parameters(revision).href();
|
||||
}
|
||||
|
||||
String revert(String namespace, String name, String revision) {
|
||||
return changesetLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("changesets").parameters().method("revert").parameters(revision).href();
|
||||
}
|
||||
}
|
||||
|
||||
public ModificationsLinks modifications() {
|
||||
|
||||
@@ -511,6 +511,10 @@
|
||||
"99UQi5FKd1": {
|
||||
"displayName": "Tags sind geschützt",
|
||||
"description": "Einige Tags sind geschützt und können nicht gelöscht werden. Der Schutz ist global konfiguriert."
|
||||
},
|
||||
"7XUd94Iwo1": {
|
||||
"displayName": "Mergekonflikte",
|
||||
"description": "Es gibt Mergekonflikte, die nicht automatisch aufgelöst werden können."
|
||||
}
|
||||
},
|
||||
"namespaceStrategies": {
|
||||
|
||||
@@ -452,6 +452,10 @@
|
||||
"99UQi5FKd1": {
|
||||
"displayName": "Tags are protected",
|
||||
"description": "Some tags are protected and cannot be removed. The protection is configured globally."
|
||||
},
|
||||
"7XUd94Iwo1": {
|
||||
"displayName": "Merge conflicts",
|
||||
"description": "There are merge conflicts that cannot be resolved automatically."
|
||||
}
|
||||
},
|
||||
"healthChecksFailures": {
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.shiro.authz.UnauthorizedException;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import org.apache.shiro.subject.support.SubjectThreadState;
|
||||
import org.apache.shiro.util.ThreadContext;
|
||||
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Answers;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -43,6 +44,9 @@ import sonia.scm.repository.api.Command;
|
||||
import sonia.scm.repository.api.LogCommandBuilder;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.repository.api.RevertCommandBuilder;
|
||||
import sonia.scm.repository.api.RevertCommandResult;
|
||||
import sonia.scm.web.JsonMockHttpRequest;
|
||||
import sonia.scm.web.JsonMockHttpResponse;
|
||||
import sonia.scm.web.RestDispatcher;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
@@ -55,12 +59,13 @@ import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mock.Strictness.LENIENT;
|
||||
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
|
||||
@@ -84,12 +89,15 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
@Mock(strictness = LENIENT)
|
||||
private LogCommandBuilder logCommandBuilder;
|
||||
|
||||
@Mock
|
||||
private TagCollectionToDtoMapper tagCollectionToDtoMapper;
|
||||
@Mock(strictness = LENIENT, answer = Answers.RETURNS_DEEP_STUBS)
|
||||
private ChangesetToParentDtoMapper changesetToParentDtoMapper;
|
||||
|
||||
@InjectMocks
|
||||
private ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@Mock
|
||||
private TagCollectionToDtoMapper tagCollectionToDtoMapper;
|
||||
|
||||
@InjectMocks
|
||||
private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper;
|
||||
@@ -100,7 +108,7 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
@BeforeEach
|
||||
void prepareEnvironment() {
|
||||
changesetCollectionToDtoMapper = new ChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks);
|
||||
changesetRootResource = new ChangesetRootResource(serviceFactory, changesetCollectionToDtoMapper, changesetToChangesetDtoMapper);
|
||||
changesetRootResource = new ChangesetRootResource(serviceFactory, changesetCollectionToDtoMapper, changesetToChangesetDtoMapper, resourceLinks);
|
||||
dispatcher.addSingletonResource(getRepositoryRootResource());
|
||||
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService);
|
||||
when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService);
|
||||
@@ -136,12 +144,12 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
.accept(VndMediaType.CHANGESET_COLLECTION);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
assertEquals(200, response.getStatus());
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
log.info("Response :{}", response.getContentAsString());
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit)));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"id\":\"%s\"", id));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"name\":\"%s\"", authorName));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"mail\":\"%s\"", authorEmail));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"description\":\"%s\"", commit));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -164,12 +172,12 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
.accept(VndMediaType.CHANGESET_COLLECTION);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
assertEquals(200, response.getStatus());
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
log.info("Response :{}", response.getContentAsString());
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail)));
|
||||
assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit)));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"id\":\"%s\"", id));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"name\":\"%s\"", authorName));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"mail\":\"%s\"", authorEmail));
|
||||
assertThat(response.getContentAsString()).contains(String.format("\"description\":\"%s\"", commit));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -180,14 +188,13 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
private final String authorName = "name";
|
||||
private final String authorEmail = "em@i.l";
|
||||
private final String commit = "my branch commit";
|
||||
private final Changeset changeset = new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit);
|
||||
|
||||
private final JsonMockHttpResponse response = new JsonMockHttpResponse();
|
||||
|
||||
@BeforeEach
|
||||
void prepareExistingChangeset() throws URISyntaxException, IOException {
|
||||
when(logCommandBuilder.getChangeset(id)).thenReturn(
|
||||
new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)
|
||||
);
|
||||
void prepareExistingChangeset() throws IOException {
|
||||
when(logCommandBuilder.getChangeset(id)).thenReturn(changeset);
|
||||
}
|
||||
|
||||
private void executeRequest() throws URISyntaxException {
|
||||
@@ -229,6 +236,14 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
assertThat(response.getContentAsJson().get("_links").get("containedInTags").get("href").asText())
|
||||
.isEqualTo("/v2/repositories/space/repo/tags/contains/revision_123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotReturnRevertLinkWithoutParent() throws URISyntaxException {
|
||||
when(repositoryService.isSupported(Command.REVERT)).thenReturn(true);
|
||||
executeRequest();
|
||||
|
||||
assertThat(response.getContentAsJson().get("_links").get("revert")).isNullOrEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -240,7 +255,104 @@ class ChangesetRootResourceTest extends RepositoryTestBase {
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(404, response.getStatus());
|
||||
assertThat(response.getStatus()).isEqualTo(404);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Revert {
|
||||
|
||||
@Mock(answer = Answers.RETURNS_SELF)
|
||||
private RevertCommandBuilder revertCommandBuilder;
|
||||
|
||||
@BeforeEach
|
||||
void prepareRevertCommand() {
|
||||
when(repositoryService.getRevertCommand()).thenReturn(revertCommandBuilder);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCallRevertCommand() throws URISyntaxException {
|
||||
when(repositoryService.getRevertCommand().execute()).thenReturn(RevertCommandResult.success("42"));
|
||||
JsonMockHttpRequest request = JsonMockHttpRequest
|
||||
.post(CHANGESET_URL + "23/revert")
|
||||
.contentType(VndMediaType.REVERT)
|
||||
.json("{'branch':'main','message':'revert message'}");
|
||||
|
||||
JsonMockHttpResponse response = new JsonMockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(revertCommandBuilder).setRevision("23");
|
||||
verify(revertCommandBuilder).setMessage("revert message");
|
||||
verify(revertCommandBuilder).setBranch("main");
|
||||
verify(revertCommandBuilder).execute();
|
||||
assertThat(response.getStatus()).isEqualTo(201);
|
||||
assertThat(response.getContentAsJson().get("revision").asText()).isEqualTo("42");
|
||||
assertThat(response.getOutputHeaders().getFirst("Location")).hasToString("/v2/repositories/space/repo/changesets/42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCallRevertCommandForDefaultMessage() throws URISyntaxException {
|
||||
when(repositoryService.getRevertCommand().execute()).thenReturn(RevertCommandResult.success("42"));
|
||||
JsonMockHttpRequest request = JsonMockHttpRequest
|
||||
.post(CHANGESET_URL + "23/revert")
|
||||
.contentType(VndMediaType.REVERT)
|
||||
.json("{'branch':'main'}");
|
||||
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(revertCommandBuilder).setRevision("23");
|
||||
verify(revertCommandBuilder, never()).setMessage(anyString());
|
||||
verify(revertCommandBuilder).setBranch("main");
|
||||
verify(revertCommandBuilder).execute();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCallRevertCommandForDefaultBranch() throws URISyntaxException {
|
||||
when(repositoryService.getRevertCommand().execute()).thenReturn(RevertCommandResult.success("42"));
|
||||
JsonMockHttpRequest request = JsonMockHttpRequest
|
||||
.post(CHANGESET_URL + "23/revert")
|
||||
.contentType(VndMediaType.REVERT)
|
||||
.json("{'message':'revert message'}");
|
||||
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(revertCommandBuilder).setRevision("23");
|
||||
verify(revertCommandBuilder).setMessage("revert message");
|
||||
verify(revertCommandBuilder, never()).setBranch(anyString());
|
||||
verify(revertCommandBuilder).execute();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWithoutPushPermission() throws URISyntaxException {
|
||||
doThrow(new UnauthorizedException("push"))
|
||||
.when(subject).checkPermission("repository:push:repoId");
|
||||
JsonMockHttpRequest request = JsonMockHttpRequest
|
||||
.post(CHANGESET_URL + "23/revert")
|
||||
.contentType(VndMediaType.REVERT)
|
||||
.json("{'branch':'main','message':'revert message'}");
|
||||
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(revertCommandBuilder, never()).execute();
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWithConflicts() throws URISyntaxException {
|
||||
when(repositoryService.getRevertCommand().execute())
|
||||
.thenReturn(RevertCommandResult.failure(List.of("file/with/conflict")));
|
||||
|
||||
JsonMockHttpRequest request = JsonMockHttpRequest
|
||||
.post(CHANGESET_URL + "23/revert")
|
||||
.contentType(VndMediaType.REVERT)
|
||||
.json("{}");
|
||||
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(409);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5273,7 +5273,7 @@
|
||||
"@babel/runtime" "^7.12.5"
|
||||
react-error-boundary "^3.1.0"
|
||||
|
||||
"@testing-library/react@12.1.5":
|
||||
"@testing-library/react@12.1.5", "@testing-library/react@^12.1.5":
|
||||
version "12.1.5"
|
||||
resolved "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz"
|
||||
integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==
|
||||
|
||||
Reference in New Issue
Block a user