Introduce Git Revert functionality to SCM-Manager

This commit is contained in:
Till-André Diegeler
2025-02-27 11:11:57 +01:00
parent 99f6422577
commit f0f7e922bf
72 changed files with 2612 additions and 649 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -84,6 +84,24 @@ Es muss lediglich ein gewünschter Name angegeben werden, welcher die gleichen F
![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png)
#### 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.
![Repository-Code-Changeset-Revert](assets/repository-code-changeset-revert.png)
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.
![Repository-Code-Changeset-Revert-Modal](assets/repository-code-changeset-revert-modal.png)
### Datei Details
Nach einem Klick auf eine Datei in den Sources landet man in der Detailansicht der Datei. Dabei sind je nach Dateiformat unterschiedliche Ansichten zu sehen:

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -85,6 +85,24 @@ Only a name has to be provided that meets the same formatting conditions as bran
![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png)
#### 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.
![Repository-Code-Changeset-Revert](assets/repository-code-changeset-revert.png)
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.
![Repository-Code-Changeset-Revert-Modal](assets/repository-code-changeset-revert-modal.png)
### File Details
After clicking on a file in the sources, the details of the file are shown. Depending on the format of the file, there are different views:

View File

@@ -0,0 +1,2 @@
- type: added
description: Git revert commit functionality

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

View File

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

View File

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

View File

@@ -82,5 +82,10 @@ public enum Command
/**
* @since 2.39.0
*/
CHANGESETS
CHANGESETS,
/**
* @since 3.8
*/
REVERT
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} />
) : (
<>&nbsp;</>
);
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>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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