diff --git a/scm-core/src/main/java/sonia/scm/BadRequestException.java b/scm-core/src/main/java/sonia/scm/BadRequestException.java new file mode 100644 index 0000000000..544ed75a0b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/BadRequestException.java @@ -0,0 +1,9 @@ +package sonia.scm; + +import java.util.List; + +public abstract class BadRequestException extends ExceptionWithContext { + public BadRequestException(List context, String message) { + super(context, message); + } +} diff --git a/scm-core/src/main/java/sonia/scm/NotSupportedFeatureException.java b/scm-core/src/main/java/sonia/scm/FeatureNotSupportedException.java similarity index 88% rename from scm-core/src/main/java/sonia/scm/NotSupportedFeatureException.java rename to scm-core/src/main/java/sonia/scm/FeatureNotSupportedException.java index daf996ee6c..2d64af4318 100644 --- a/scm-core/src/main/java/sonia/scm/NotSupportedFeatureException.java +++ b/scm-core/src/main/java/sonia/scm/FeatureNotSupportedException.java @@ -40,13 +40,14 @@ import java.util.Collections; * @author Sebastian Sdorra * @version 1.6 */ -public class NotSupportedFeatureException extends ExceptionWithContext { +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +public class FeatureNotSupportedException extends BadRequestException { private static final long serialVersionUID = 256498734456613496L; private static final String CODE = "9SR8G0kmU1"; - public NotSupportedFeatureException(String feature) + public FeatureNotSupportedException(String feature) { super(Collections.emptyList(),createMessage(feature)); } diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java index a3e8a1da73..1e9cc3d374 100644 --- a/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java @@ -38,7 +38,7 @@ package sonia.scm.repository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.NotSupportedFeatureException; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.SCMContextProvider; import sonia.scm.event.ScmEventBus; @@ -167,12 +167,12 @@ public abstract class AbstractRepositoryHandler * * @return * - * @throws NotSupportedFeatureException + * @throws FeatureNotSupportedException */ @Override public ImportHandler getImportHandler() { - throw new NotSupportedFeatureException("import"); + throw new FeatureNotSupportedException("import"); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/Feature.java b/scm-core/src/main/java/sonia/scm/repository/Feature.java index 1db351267d..1bcaef4de5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Feature.java +++ b/scm-core/src/main/java/sonia/scm/repository/Feature.java @@ -45,5 +45,10 @@ public enum Feature * The default branch of the repository is a combined branch of all * repository branches. */ - COMBINED_DEFAULT_BRANCH + COMBINED_DEFAULT_BRANCH, + /** + * The repository supports computation of incoming changes (either diff or list of changesets) of one branch + * in respect to another target branch. + */ + INCOMING_REVISION } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java index cb19cb7f5e..aaa090827a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java @@ -36,7 +36,7 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import sonia.scm.Handler; -import sonia.scm.NotSupportedFeatureException; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.plugin.ExtensionPoint; /** @@ -59,9 +59,9 @@ public interface RepositoryHandler * @return {@link ImportHandler} for the repository type of this handler * @since 1.12 * - * @throws NotSupportedFeatureException + * @throws FeatureNotSupportedException */ - public ImportHandler getImportHandler() throws NotSupportedFeatureException; + public ImportHandler getImportHandler() throws FeatureNotSupportedException; /** * Returns informations about the version of the RepositoryHandler. diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java index 7217d0e97a..9e7094d5bf 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java @@ -38,6 +38,8 @@ package sonia.scm.repository.api; import com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.FeatureNotSupportedException; +import sonia.scm.repository.Feature; import sonia.scm.repository.spi.DiffCommand; import sonia.scm.repository.spi.DiffCommandRequest; import sonia.scm.util.IOUtil; @@ -45,6 +47,7 @@ import sonia.scm.util.IOUtil; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -85,10 +88,12 @@ public final class DiffCommandBuilder * only be called from the {@link RepositoryService}. * * @param diffCommand implementation of {@link DiffCommand} + * @param supportedFeatures The supported features of the provider */ - DiffCommandBuilder(DiffCommand diffCommand) + DiffCommandBuilder(DiffCommand diffCommand, Set supportedFeatures) { this.diffCommand = diffCommand; + this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -174,7 +179,8 @@ public final class DiffCommandBuilder } /** - * Show the difference only for the given revision. + * Show the difference only for the given revision or (using {@link #setAncestorChangeset(String)}) between this + * and another revision. * * * @param revision revision for difference @@ -187,6 +193,22 @@ public final class DiffCommandBuilder return this; } + /** + * Compute the incoming changes of the branch set with {@link #setRevision(String)} in respect to the changeset given + * here. In other words: What changes would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ + public DiffCommandBuilder setAncestorChangeset(String revision) + { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name()); + } + request.setAncestorChangeset(revision); + + return this; + } //~--- get methods ---------------------------------------------------------- @@ -215,6 +237,7 @@ public final class DiffCommandBuilder /** implementation of the diff command */ private final DiffCommand diffCommand; + private Set supportedFeatures; /** request for the diff command implementation */ private final DiffCommandRequest request = new DiffCommandRequest(); diff --git a/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java index 73062a0244..917b81391f 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java @@ -39,10 +39,12 @@ import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.Feature; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryCacheKey; @@ -51,6 +53,7 @@ import sonia.scm.repository.spi.LogCommandRequest; import java.io.IOException; import java.io.Serializable; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -104,19 +107,20 @@ public final class LogCommandBuilder /** * Constructs a new {@link LogCommandBuilder}, this constructor should * only be called from the {@link RepositoryService}. - * - * @param cacheManager cache manager + * @param cacheManager cache manager * @param logCommand implementation of the {@link LogCommand} * @param repository repository to query * @param preProcessorUtil + * @param supportedFeatures The supported features of the provider */ LogCommandBuilder(CacheManager cacheManager, LogCommand logCommand, - Repository repository, PreProcessorUtil preProcessorUtil) + Repository repository, PreProcessorUtil preProcessorUtil, Set supportedFeatures) { this.cache = cacheManager.getCache(CACHE_NAME); this.logCommand = logCommand; this.repository = repository; this.preProcessorUtil = preProcessorUtil; + this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -397,7 +401,17 @@ public final class LogCommandBuilder return this; } + /** + * Compute the incoming changes of the branch set with {@link #setBranch(String)} in respect to the changeset given + * here. In other words: What changesets would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name()); + } request.setAncestorChangeset(ancestorChangeset); return this; } @@ -527,6 +541,7 @@ public final class LogCommandBuilder /** Field description */ private final PreProcessorUtil preProcessorUtil; + private Set supportedFeatures; /** repository to query */ private final Repository repository; diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java index 881a374864..8fcfc937e5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java @@ -15,7 +15,7 @@ import sonia.scm.repository.spi.MergeCommandRequest; * * To actually merge feature_branch into integration_branch do this: *

- *     repositoryService.gerMergeCommand()
+ *     repositoryService.getMergeCommand()
  *       .setBranchToMerge("feature_branch")
  *       .setTargetBranch("integration_branch")
  *       .executeMerge();
@@ -33,7 +33,7 @@ import sonia.scm.repository.spi.MergeCommandRequest;
  *
  * To check whether they can be merged without conflicts beforehand do this:
  * 

- *     repositoryService.gerMergeCommand()
+ *     repositoryService.getMergeCommand()
  *       .setBranchToMerge("feature_branch")
  *       .setTargetBranch("integration_branch")
  *       .dryRun()
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
index fe0529e6b5..ad53c3a8f7 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
@@ -221,7 +221,7 @@ public final class RepositoryService implements Closeable {
     logger.debug("create diff command for repository {}",
       repository.getNamespaceAndName());
 
-    return new DiffCommandBuilder(provider.getDiffCommand());
+    return new DiffCommandBuilder(provider.getDiffCommand(), provider.getSupportedFeatures());
   }
 
   /**
@@ -253,7 +253,7 @@ public final class RepositoryService implements Closeable {
       repository.getNamespaceAndName());
 
     return new LogCommandBuilder(cacheManager, provider.getLogCommand(),
-      repository, preProcessorUtil);
+      repository, preProcessorUtil, provider.getSupportedFeatures());
   }
 
   /**
@@ -363,8 +363,8 @@ public final class RepositoryService implements Closeable {
    *                                      by the implementation of the repository service provider.
    * @since 2.0.0
    */
-  public MergeCommandBuilder gerMergeCommand() {
-    logger.debug("create unbundle command for repository {}",
+  public MergeCommandBuilder getMergeCommand() {
+    logger.debug("create merge command for repository {}",
       repository.getNamespaceAndName());
 
     return new MergeCommandBuilder(provider.getMergeCommand());
diff --git a/scm-core/src/main/java/sonia/scm/security/AccessToken.java b/scm-core/src/main/java/sonia/scm/security/AccessToken.java
index c2a5f4b747..ac7700b030 100644
--- a/scm-core/src/main/java/sonia/scm/security/AccessToken.java
+++ b/scm-core/src/main/java/sonia/scm/security/AccessToken.java
@@ -80,8 +80,20 @@ public interface AccessToken {
    */
   Date getExpiration();
 
+  /**
+   * Returns refresh expiration of token.
+   *
+   * @return refresh expiration
+   */
   Optional getRefreshExpiration();
 
+  /**
+   * Returns id of the parent key.
+   *
+   * @return parent key id
+   */
+  Optional getParentKey();
+
   /**
    * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this
    * token. For example we could issue a token which can only be used to read a single repository. for more informations
diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java
new file mode 100644
index 0000000000..999c693b8f
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java
@@ -0,0 +1,30 @@
+package sonia.scm.security;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Generates cookies and invalidates access token cookies.
+ *
+ * @author Sebastian Sdorra
+ * @since 2.0.0
+ */
+public interface AccessTokenCookieIssuer {
+
+  /**
+   * Creates a cookie for token authentication and attaches it to the response.
+   *
+   * @param request http servlet request
+   * @param response http servlet response
+   * @param accessToken access token
+   */
+  void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken);
+  /**
+   * Invalidates the authentication cookie.
+   *
+   * @param request http servlet request
+   * @param response http servlet response
+   */
+  void invalidate(HttpServletRequest request, HttpServletResponse response);
+
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java b/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java
index b4f0d81cd3..9c1fa590cc 100644
--- a/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java
+++ b/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java
@@ -164,7 +164,7 @@ public class DefaultCipherHandler implements CipherHandler {
     String result = null;
 
     try {
-      byte[] encodedInput = Base64.getDecoder().decode(value);
+      byte[] encodedInput = Base64.getUrlDecoder().decode(value);
       byte[] salt = new byte[SALT_LENGTH];
       byte[] encoded = new byte[encodedInput.length - SALT_LENGTH];
 
@@ -221,7 +221,7 @@ public class DefaultCipherHandler implements CipherHandler {
       System.arraycopy(salt, 0, result, 0, SALT_LENGTH);
       System.arraycopy(encodedInput, 0, result, SALT_LENGTH,
         result.length - SALT_LENGTH);
-      res = new String(Base64.getEncoder().encode(result), ENCODING);
+      res = new String(Base64.getUrlEncoder().encode(result), ENCODING);
     } catch (IOException | GeneralSecurityException ex) {
       throw new CipherException("could not encode string", ex);
     }
diff --git a/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java b/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java
index a7f21dd304..bcdc0443ca 100644
--- a/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java
+++ b/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java
@@ -33,6 +33,10 @@
 
 package sonia.scm.store;
 
+import java.util.Optional;
+
+import static java.util.Optional.ofNullable;
+
 /**
  * ConfigurationStore for configuration objects. Note: the default
  * implementation use JAXB to marshall the configuration objects.
@@ -50,7 +54,17 @@ public interface ConfigurationStore
    *
    * @return configuration object from store
    */
-  public T get();
+  T get();
+
+  /**
+   * Returns the configuration object from store.
+   *
+   *
+   * @return configuration object from store
+   */
+  default Optional getOptional() {
+    return ofNullable(get());
+  }
 
   //~--- set methods ----------------------------------------------------------
 
@@ -60,5 +74,5 @@ public interface ConfigurationStore
    *
    * @param obejct configuration object to store
    */
-  public void set(T obejct);
+  void set(T object);
 }
diff --git a/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java b/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java
index 9a35cee0e0..c1a8863758 100644
--- a/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java
+++ b/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java
@@ -32,6 +32,10 @@
 
 package sonia.scm.store;
 
+import java.util.Optional;
+
+import static java.util.Optional.ofNullable;
+
 /**
  * Base class for {@link BlobStore} and {@link DataStore}.
  *
@@ -67,4 +71,16 @@ public interface MultiEntryStore {
    * @return item with the given id
    */
   public T get(String id);
+
+  /**
+   * Returns the item with the given id from the store.
+   *
+   *
+   * @param id id of the item to return
+   *
+   * @return item with the given id
+   */
+  default Optional getOptional(String id) {
+    return ofNullable(get(id));
+  }
 }
diff --git a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java
index caa35e0b88..b0f8117e82 100644
--- a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java
+++ b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java
@@ -1,12 +1,13 @@
 package sonia.scm.user;
 
+import sonia.scm.BadRequestException;
 import sonia.scm.ContextEntry;
-import sonia.scm.ExceptionWithContext;
 
-public class ChangePasswordNotAllowedException extends ExceptionWithContext {
+@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
+public class ChangePasswordNotAllowedException extends BadRequestException {
 
   private static final String CODE = "9BR7qpDAe1";
-  public static final String WRONG_USER_TYPE = "User of type %s are not allowed to change password";
+  public static final String WRONG_USER_TYPE = "Users of type %s are not allowed to change password";
 
   public ChangePasswordNotAllowedException(ContextEntry.ContextBuilder context, String type) {
     super(context.build(), String.format(WRONG_USER_TYPE, type));
diff --git a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java
index 93a6a7c1d1..6f1bfd9954 100644
--- a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java
+++ b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java
@@ -1,9 +1,10 @@
 package sonia.scm.user;
 
+import sonia.scm.BadRequestException;
 import sonia.scm.ContextEntry;
-import sonia.scm.ExceptionWithContext;
 
-public class InvalidPasswordException extends ExceptionWithContext {
+@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
+public class InvalidPasswordException extends BadRequestException {
 
   private static final String CODE = "8YR7aawFW1";
 
diff --git a/scm-core/src/main/java/sonia/scm/web/AbstractRepositoryJsonEnricher.java b/scm-core/src/main/java/sonia/scm/web/AbstractRepositoryJsonEnricher.java
new file mode 100644
index 0000000000..2cb4674d24
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/web/AbstractRepositoryJsonEnricher.java
@@ -0,0 +1,40 @@
+package sonia.scm.web;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import static java.util.Collections.singletonMap;
+import static sonia.scm.web.VndMediaType.REPOSITORY;
+import static sonia.scm.web.VndMediaType.REPOSITORY_COLLECTION;
+
+public abstract class AbstractRepositoryJsonEnricher extends JsonEnricherBase {
+
+  public AbstractRepositoryJsonEnricher(ObjectMapper objectMapper) {
+    super(objectMapper);
+  }
+
+  @Override
+  public void enrich(JsonEnricherContext context) {
+    if (resultHasMediaType(REPOSITORY, context)) {
+      JsonNode repositoryNode = context.getResponseEntity();
+      enrichRepositoryNode(repositoryNode);
+    } else if (resultHasMediaType(REPOSITORY_COLLECTION, context)) {
+      JsonNode repositoryCollectionNode = context.getResponseEntity().get("_embedded").withArray("repositories");
+      repositoryCollectionNode.elements().forEachRemaining(this::enrichRepositoryNode);
+    }
+  }
+
+  private void enrichRepositoryNode(JsonNode repositoryNode) {
+    String namespace = repositoryNode.get("namespace").asText();
+    String name = repositoryNode.get("name").asText();
+
+    enrichRepositoryNode(repositoryNode, namespace, name);
+  }
+
+  protected abstract void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name);
+
+  protected void addLink(JsonNode repositoryNode, String linkName, String link) {
+    JsonNode hrefNode = createObject(singletonMap("href", value(link)));
+    addPropertyNode(repositoryNode.get("_links"), linkName, hrefNode);
+  }
+}
diff --git a/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java b/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java
index 1baecb62af..6bdd321c86 100644
--- a/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java
+++ b/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java
@@ -15,7 +15,7 @@ public abstract class JsonEnricherBase implements JsonEnricher {
   }
 
   protected boolean resultHasMediaType(String mediaType, JsonEnricherContext context) {
-    return mediaType.equals(context.getResponseMediaType().toString());
+    return mediaType.equalsIgnoreCase(context.getResponseMediaType().toString());
   }
 
   protected JsonNode value(Object object) {
diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
index e2a2218d34..2a409482c8 100644
--- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
+++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
@@ -41,6 +41,8 @@ public class VndMediaType {
   public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX;
   @SuppressWarnings("squid:S2068")
   public static final String PASSWORD_OVERWRITE = PREFIX + "passwordOverwrite" + SUFFIX;
+  public static final String MERGE_RESULT = PREFIX + "mergeResult" + SUFFIX;
+  public static final String MERGE_COMMAND = PREFIX + "mergeCommand" + SUFFIX;
 
   public static final String ME = PREFIX + "me" + SUFFIX;
   public static final String SOURCE = PREFIX + "source" + SUFFIX;
diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlInstantAdapter.java b/scm-core/src/main/java/sonia/scm/xml/XmlInstantAdapter.java
new file mode 100644
index 0000000000..9b8d718851
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/xml/XmlInstantAdapter.java
@@ -0,0 +1,25 @@
+package sonia.scm.xml;
+
+import javax.xml.bind.annotation.adapters.XmlAdapter;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
+
+/**
+ * JAXB adapter for {@link Instant} objects.
+ *
+ * @since 2.0.0
+ */
+public class XmlInstantAdapter extends XmlAdapter {
+
+  @Override
+  public String marshal(Instant instant) {
+    return DateTimeFormatter.ISO_INSTANT.format(instant);
+  }
+
+  @Override
+  public Instant unmarshal(String text) {
+    TemporalAccessor parsed = DateTimeFormatter.ISO_INSTANT.parse(text);
+    return Instant.from(parsed);
+  }
+}
diff --git a/scm-core/src/test/java/sonia/scm/web/AbstractRepositoryJsonEnricherTest.java b/scm-core/src/test/java/sonia/scm/web/AbstractRepositoryJsonEnricherTest.java
new file mode 100644
index 0000000000..2c8ef76464
--- /dev/null
+++ b/scm-core/src/test/java/sonia/scm/web/AbstractRepositoryJsonEnricherTest.java
@@ -0,0 +1,107 @@
+package sonia.scm.web;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.io.Resources;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.api.v2.resources.ScmPathInfoStore;
+
+import javax.ws.rs.core.MediaType;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(MockitoExtension.class)
+class AbstractRepositoryJsonEnricherTest {
+
+  private final ObjectMapper objectMapper = new ObjectMapper();
+  private AbstractRepositoryJsonEnricher linkEnricher;
+  private JsonNode rootNode;
+
+  @BeforeEach
+  void globalSetUp() {
+    ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
+    pathInfoStore.set(() -> URI.create("/"));
+
+    linkEnricher = new AbstractRepositoryJsonEnricher(objectMapper) {
+      @Override
+      protected void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name) {
+        addLink(repositoryNode, "new-link", "/somewhere");
+      }
+    };
+  }
+
+  @Test
+  void shouldEnrichRepositories() throws IOException {
+    URL resource = Resources.getResource("sonia/scm/repository/repository-001.json");
+    rootNode = objectMapper.readTree(resource);
+
+    JsonEnricherContext context = new JsonEnricherContext(
+      URI.create("/"),
+      MediaType.valueOf(VndMediaType.REPOSITORY),
+      rootNode
+    );
+
+    linkEnricher.enrich(context);
+
+    String configLink = context.getResponseEntity()
+      .get("_links")
+      .get("new-link")
+      .get("href")
+      .asText();
+
+    assertThat(configLink).isEqualTo("/somewhere");
+  }
+
+  @Test
+  void shouldEnrichAllRepositories() throws IOException {
+    URL resource = Resources.getResource("sonia/scm/repository/repository-collection-001.json");
+    rootNode = objectMapper.readTree(resource);
+
+    JsonEnricherContext context = new JsonEnricherContext(
+      URI.create("/"),
+      MediaType.valueOf(VndMediaType.REPOSITORY_COLLECTION),
+      rootNode
+    );
+
+    linkEnricher.enrich(context);
+
+    context.getResponseEntity()
+      .get("_embedded")
+      .withArray("repositories")
+      .elements()
+      .forEachRemaining(node -> {
+        String configLink = node
+          .get("_links")
+          .get("new-link")
+          .get("href")
+          .asText();
+
+        assertThat(configLink).isEqualTo("/somewhere");
+      });
+  }
+
+  @Test
+  void shouldNotModifyObjectsWithUnsupportedMediaType() throws IOException {
+    URL resource = Resources.getResource("sonia/scm/repository/repository-001.json");
+    rootNode = objectMapper.readTree(resource);
+    JsonEnricherContext context = new JsonEnricherContext(
+      URI.create("/"),
+      MediaType.valueOf(VndMediaType.USER),
+      rootNode
+    );
+
+    linkEnricher.enrich(context);
+
+    boolean hasNewPullRequestLink = context.getResponseEntity()
+      .get("_links")
+      .has("new-link");
+
+    assertThat(hasNewPullRequestLink).isFalse();
+  }
+}
diff --git a/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java b/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java
index 43ed4940fa..2f53ae8102 100644
--- a/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java
+++ b/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java
@@ -23,6 +23,14 @@ public class JsonEnricherBaseTest {
     assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_XML, context)).isFalse();
   }
 
+  @Test
+  public void testResultHasMediaTypeWithCamelCaseMediaType() {
+    String mediaType = "application/hitchhikersGuideToTheGalaxy";
+    JsonEnricherContext context = new JsonEnricherContext(null, MediaType.valueOf(mediaType), null);
+
+    assertThat(enricher.resultHasMediaType(mediaType, context)).isTrue();
+  }
+
   @Test
   public void testAppendLink() {
     ObjectNode root = objectMapper.createObjectNode();
diff --git a/scm-core/src/test/java/sonia/scm/xml/XmlInstantAdapterTest.java b/scm-core/src/test/java/sonia/scm/xml/XmlInstantAdapterTest.java
new file mode 100644
index 0000000000..eb1ea86aee
--- /dev/null
+++ b/scm-core/src/test/java/sonia/scm/xml/XmlInstantAdapterTest.java
@@ -0,0 +1,47 @@
+package sonia.scm.xml;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junitpioneer.jupiter.TempDirectory;
+
+import javax.xml.bind.JAXB;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.nio.file.Path;
+import java.time.Instant;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@ExtendWith(TempDirectory.class)
+class XmlInstantAdapterTest {
+
+  @Test
+  void shouldMarshalAndUnmarshalInstant(@TempDirectory.TempDir Path tempDirectory) {
+    Path path = tempDirectory.resolve("instant.xml");
+
+    Instant instant = Instant.now();
+    InstantObject object = new InstantObject(instant);
+    JAXB.marshal(object, path.toFile());
+
+    InstantObject unmarshaled = JAXB.unmarshal(path.toFile(), InstantObject.class);
+    assertEquals(instant, unmarshaled.instant);
+  }
+
+  @XmlRootElement(name = "instant-object")
+  @XmlAccessorType(XmlAccessType.FIELD)
+  public static class InstantObject {
+
+    @XmlJavaTypeAdapter(XmlInstantAdapter.class)
+    private Instant instant;
+
+    public InstantObject() {
+    }
+
+    InstantObject(Instant instant) {
+      this.instant = instant;
+    }
+  }
+
+}
diff --git a/scm-core/src/test/resources/sonia/scm/repository/repository-001.json b/scm-core/src/test/resources/sonia/scm/repository/repository-001.json
new file mode 100644
index 0000000000..43ea136942
--- /dev/null
+++ b/scm-core/src/test/resources/sonia/scm/repository/repository-001.json
@@ -0,0 +1,42 @@
+{
+  "creationDate": "2018-11-09T09:48:32.732Z",
+  "description": "Handling static webresources made easy",
+  "healthCheckFailures": [],
+  "lastModified": "2018-11-09T09:49:20.973Z",
+  "namespace": "scmadmin",
+  "name": "web-resources",
+  "archived": false,
+  "type": "git",
+  "_links": {
+    "self": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+    },
+    "delete": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+    },
+    "update": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+    },
+    "permissions": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/"
+    },
+    "protocol": [
+      {
+        "href": "http://localhost:8081/scm/repo/scmadmin/web-resources",
+        "name": "http"
+      }
+    ],
+    "tags": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/"
+    },
+    "branches": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/"
+    },
+    "changesets": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/"
+    },
+    "sources": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/"
+    }
+  }
+}
diff --git a/scm-core/src/test/resources/sonia/scm/repository/repository-collection-001.json b/scm-core/src/test/resources/sonia/scm/repository/repository-collection-001.json
new file mode 100644
index 0000000000..f4eeb24bbc
--- /dev/null
+++ b/scm-core/src/test/resources/sonia/scm/repository/repository-collection-001.json
@@ -0,0 +1,106 @@
+{
+  "page": 0,
+  "pageTotal": 1,
+  "_links": {
+    "self": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10"
+    },
+    "first": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10"
+    },
+    "last": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10"
+    },
+    "create": {
+      "href": "http://localhost:8081/scm/api/v2/repositories/"
+    }
+  },
+  "_embedded": {
+    "repositories": [
+      {
+        "creationDate": "2018-11-09T09:48:32.732Z",
+        "description": "Handling static webresources made easy",
+        "healthCheckFailures": [],
+        "lastModified": "2018-11-09T09:49:20.973Z",
+        "namespace": "scmadmin",
+        "name": "web-resources",
+        "archived": false,
+        "type": "git",
+        "_links": {
+          "self": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+          },
+          "delete": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+          },
+          "update": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+          },
+          "permissions": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/"
+          },
+          "protocol": [
+            {
+              "href": "http://localhost:8081/scm/repo/scmadmin/web-resources",
+              "name": "http"
+            }
+          ],
+          "tags": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/"
+          },
+          "branches": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/"
+          },
+          "changesets": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/"
+          },
+          "sources": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/"
+          }
+        }
+      },
+      {
+        "creationDate": "2018-11-09T09:48:32.732Z",
+        "description": "Handling static webresources made easy",
+        "healthCheckFailures": [],
+        "lastModified": "2018-11-09T09:49:20.973Z",
+        "namespace": "scmadmin",
+        "name": "web-resources",
+        "archived": false,
+        "type": "git",
+        "_links": {
+          "self": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+          },
+          "delete": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+          },
+          "update": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
+          },
+          "permissions": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/"
+          },
+          "protocol": [
+            {
+              "href": "http://localhost:8081/scm/repo/scmadmin/web-resources",
+              "name": "http"
+            }
+          ],
+          "tags": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/"
+          },
+          "branches": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/"
+          },
+          "changesets": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/"
+          },
+          "sources": {
+            "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/"
+          }
+        }
+      }
+    ]
+  }
+}
diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java
index 099ab53baa..d37a150723 100644
--- a/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java
+++ b/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java
@@ -58,8 +58,6 @@ public abstract class FileBasedStoreFactory {
   private RepositoryLocationResolver repositoryLocationResolver;
   private Store store;
 
-  private File storeDirectory;
-
   protected FileBasedStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, Store store) {
     this.contextProvider = contextProvider;
     this.repositoryLocationResolver = repositoryLocationResolver;
@@ -75,17 +73,16 @@ public abstract class FileBasedStoreFactory {
   }
 
   protected File getStoreLocation(String name, Class type, Repository repository) {
-    if (storeDirectory == null) {
-      if (repository != null) {
-        LOG.debug("create store with type: {}, name: {} and repository: {}", type, name, repository.getNamespaceAndName());
-        storeDirectory = this.getStoreDirectory(store, repository);
-      } else {
-        LOG.debug("create store with type: {} and name: {} ", type, name);
-        storeDirectory = this.getStoreDirectory(store);
-      }
-      IOUtil.mkdirs(storeDirectory);
+    File storeDirectory;
+    if (repository != null) {
+      LOG.debug("create store with type: {}, name: {} and repository: {}", type, name, repository.getNamespaceAndName());
+      storeDirectory = this.getStoreDirectory(store, repository);
+    } else {
+      LOG.debug("create store with type: {} and name: {} ", type, name);
+      storeDirectory = this.getStoreDirectory(store);
     }
-    return new File(this.storeDirectory, name);
+    IOUtil.mkdirs(storeDirectory);
+    return new File(storeDirectory, name);
   }
 
   /**
diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/Store.java b/scm-dao-xml/src/main/java/sonia/scm/store/Store.java
index 6e5cbcdf65..511ef8323e 100644
--- a/scm-dao-xml/src/main/java/sonia/scm/store/Store.java
+++ b/scm-dao-xml/src/main/java/sonia/scm/store/Store.java
@@ -8,6 +8,7 @@ public enum Store {
   BLOB("blob");
 
   private static final String GLOBAL_STORE_BASE_DIRECTORY = "var";
+  private static final String STORE_DIRECTORY = "store";
 
   private String directory;
 
@@ -17,17 +18,17 @@ public enum Store {
   }
 
   /**
-   * Get the relkative store directory path to be stored in the repository root
+   * Get the relative store directory path to be stored in the repository root
    * 

* The repository store directories are: - * repo_base_dir/config/ - * repo_base_dir/blob/ - * repo_base_dir/data/ + * repo_base_dir/store/config/ + * repo_base_dir/store/blob/ + * repo_base_dir/store/data/ * * @return the relative store directory path to be stored in the repository root */ public String getRepositoryStoreDirectory() { - return directory; + return STORE_DIRECTORY + File.separator + directory; } /** diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index 6377574498..3145b6a338 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -12,6 +12,6 @@ "@scm-manager/ui-extensions": "^0.1.1" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java index 3481ccd0d1..8e1a6e5ef3 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java +++ b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java @@ -48,6 +48,7 @@ import org.eclipse.jgit.lib.RepositoryCache; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.spi.HookEventFacade; +import sonia.scm.web.CollectingPackParserListener; import sonia.scm.web.GitReceiveHook; //~--- JDK imports ------------------------------------------------------------ @@ -64,10 +65,10 @@ public class ScmTransportProtocol extends TransportProtocol { /** Field description */ - private static final String NAME = "scm"; + public static final String NAME = "scm"; /** Field description */ - private static final Set SCHEMES = ImmutableSet.of("scm"); + private static final Set SCHEMES = ImmutableSet.of(NAME); //~--- constructors --------------------------------------------------------- @@ -234,6 +235,8 @@ public class ScmTransportProtocol extends TransportProtocol pack.setPreReceiveHook(hook); pack.setPostReceiveHook(hook); + + CollectingPackParserListener.set(pack); } return pack; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java index 4d83d14d5d..95225c9e30 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java @@ -43,7 +43,6 @@ import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.SCMContextProvider; -import sonia.scm.io.FileSystem; import sonia.scm.plugin.Extension; import sonia.scm.repository.spi.GitRepositoryServiceProvider; import sonia.scm.schedule.Scheduler; @@ -97,7 +96,7 @@ public class GitRepositoryHandler private final GitWorkdirFactory workdirFactory; private Task task; - + //~--- constructors --------------------------------------------------------- @Inject @@ -126,7 +125,7 @@ public class GitRepositoryHandler scheduleGc(config.getGcExpression()); super.setConfig(config); } - + private void scheduleGc(String expression) { synchronized (LOCK){ @@ -142,7 +141,7 @@ public class GitRepositoryHandler } } } - + /** * Method description * diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java index 5e9eac5230..be91d06361 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java @@ -4,8 +4,10 @@ import com.google.common.base.Strings; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.MergeCommand.FastForwardMode; import org.eclipse.jgit.api.MergeResult; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.RefNotFoundException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.merge.MergeStrategy; @@ -15,6 +17,7 @@ import org.slf4j.LoggerFactory; import sonia.scm.repository.GitWorkdirFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.user.User; @@ -22,6 +25,9 @@ import sonia.scm.user.User; import java.io.IOException; import java.text.MessageFormat; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + public class GitMergeCommand extends AbstractGitCommand implements MergeCommand { private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class); @@ -40,6 +46,8 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand @Override public MergeCommandResult merge(MergeCommandRequest request) { + RepositoryPermissions.push(context.getRepository().getId()).check(); + try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) { Repository repository = workingCopy.get(); logger.debug("cloned repository to folder {}", repository.getWorkTree()); @@ -88,20 +96,43 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } } - private void checkOutTargetBranch() { + private void checkOutTargetBranch() throws IOException { try { clone.checkout().setName(target).call(); + } catch (RefNotFoundException e) { + logger.trace("could not checkout target branch {} for merge directly; trying to create local branch", target, e); + checkOutTargetAsNewLocalBranch(); } catch (GitAPIException e) { throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge: " + target, e); } } + private void checkOutTargetAsNewLocalBranch() throws IOException { + try { + ObjectId targetRevision = resolveRevision(target); + if (targetRevision == null) { + throw notFound(entity("revision", target).in(context.getRepository())); + } + clone.checkout().setStartPoint(targetRevision.getName()).setName(target).setCreateBranch(true).call(); + } catch (RefNotFoundException e) { + logger.debug("could not checkout target branch {} for merge as local branch", target, e); + throw notFound(entity("revision", target).in(context.getRepository())); + } catch (GitAPIException e) { + throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge as local branch: " + target, e); + } + } + private MergeResult doMergeInClone() throws IOException { MergeResult result; try { + ObjectId sourceRevision = resolveRevision(toMerge); + if (sourceRevision == null) { + throw notFound(entity("revision", toMerge).in(context.getRepository())); + } result = clone.merge() + .setFastForward(FastForwardMode.NO_FF) .setCommit(false) // we want to set the author manually - .include(toMerge, resolveRevision(toMerge)) + .include(toMerge, sourceRevision) .call(); } catch (GitAPIException e) { throw new InternalRepositoryException(context.getRepository(), "could not merge branch " + toMerge + " into " + target, e); @@ -113,10 +144,12 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand logger.debug("merged branch {} into {}", toMerge, target); Person authorToUse = determineAuthor(); try { - clone.commit() - .setAuthor(authorToUse.getName(), authorToUse.getMail()) - .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target)) - .call(); + if (!clone.status().call().isClean()) { + clone.commit() + .setAuthor(authorToUse.getName(), authorToUse.getMail()) + .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target)) + .call(); + } } catch (GitAPIException e) { throw new InternalRepositoryException(context.getRepository(), "could not commit merge between branch " + toMerge + " and " + target, e); } @@ -147,7 +180,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand try { clone.push().call(); } catch (GitAPIException e) { - throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + toMerge + " to origin", e); + throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + target + " to origin", e); } logger.debug("pushed merged branch {}", target); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index ae1af333bc..936962eaba 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -34,11 +34,13 @@ package sonia.scm.repository.spi; import com.google.common.collect.ImmutableSet; +import sonia.scm.repository.Feature; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; import sonia.scm.repository.api.Command; import java.io.IOException; +import java.util.EnumSet; import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -66,6 +68,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.PULL, Command.MERGE ); + protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); //J+ //~--- constructors --------------------------------------------------------- @@ -246,6 +249,10 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider return new GitMergeCommand(context, repository, handler.getWorkdirFactory()); } + @Override + public Set getSupportedFeatures() { + return FEATURES; + } //~--- fields --------------------------------------------------------------- /** Field description */ diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java index 22fce5f330..f12818aa80 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java @@ -3,6 +3,7 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ScmTransportProtocol; import org.eclipse.jgit.util.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,12 +46,16 @@ public class SimpleGitWorkdirFactory implements GitWorkdirFactory { protected Repository cloneRepository(File bareRepository, File target) throws GitAPIException { return Git.cloneRepository() - .setURI(bareRepository.getAbsolutePath()) + .setURI(createScmTransportProtocolUri(bareRepository)) .setDirectory(target) .call() .getRepository(); } + private String createScmTransportProtocolUri(File bareRepository) { + return ScmTransportProtocol.NAME + "://" + bareRepository.getAbsolutePath(); + } + private void close(Repository repository) { repository.close(); try { diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitMergeInformation.js b/scm-plugins/scm-git-plugin/src/main/js/GitMergeInformation.js new file mode 100644 index 0000000000..0e6a9d6af6 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/js/GitMergeInformation.js @@ -0,0 +1,59 @@ +//@flow +import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; + +type Props = { + repository: Repository, + target: string, + source: string, + t: string => string +}; + +class GitMergeInformation extends React.Component { + render() { + const { source, target, t } = this.props; + + return ( +

+

{t("scm-git-plugin.information.merge.heading")}

+ {t("scm-git-plugin.information.merge.checkout")} +
+          git checkout {target}
+        
+ {t("scm-git-plugin.information.merge.update")} +
+          
+            git pull
+          
+        
+ {t("scm-git-plugin.information.merge.merge")} +
+          
+            git merge {source}
+          
+        
+ {t("scm-git-plugin.information.merge.resolve")} +
+          
+            git add <conflict file>
+          
+        
+ {t("scm-git-plugin.information.merge.commit")} +
+          
+            git commit -m "Merge {source} into {target}"
+          
+        
+ {t("scm-git-plugin.information.merge.push")} +
+          
+            git push
+          
+        
+
+ ); + } +} + +export default translate("plugins")(GitMergeInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/index.js b/scm-plugins/scm-git-plugin/src/main/js/index.js index 3f91405509..bdeda4cd0e 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/index.js +++ b/scm-plugins/scm-git-plugin/src/main/js/index.js @@ -5,6 +5,7 @@ import GitAvatar from "./GitAvatar"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; import GitGlobalConfiguration from "./GitGlobalConfiguration"; +import GitMergeInformation from "./GitMergeInformation"; // repository @@ -13,6 +14,7 @@ const gitPredicate = (props: Object) => { }; binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate); +binder.bind("repos.repository-merge.information", GitMergeInformation, gitPredicate); binder.bind("repos.repository-avatar", GitAvatar, gitPredicate); // global config diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index 483bff74c4..c02cd9e101 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -3,7 +3,16 @@ "information": { "clone" : "Clone the repository", "create" : "Create a new repository", - "replace" : "Push an existing repository" + "replace" : "Push an existing repository", + "merge": { + "heading": "How to merge source branch into target branch", + "checkout": "1. Make sure your workspace is clean and checkout target branch", + "update": "2. Update workspace", + "merge": "3. Merge source branch", + "resolve": "4. Resolve merge conflicts and add corrected files to index", + "commit": "5. Commit", + "push": "6. Push your merge" + } }, "config": { "link": "Git", diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index 1fca7814ed..7e50b48b9a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java @@ -6,20 +6,33 @@ import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.ScmTransportProtocol; +import org.eclipse.jgit.transport.Transport; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Person; +import sonia.scm.repository.PreProcessorUtil; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.user.User; import java.io.IOException; +import static com.google.inject.util.Providers.of; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") public class GitMergeCommandTest extends AbstractGitCommandTestBase { private static final String REALM = "AdminRealm"; @@ -27,6 +40,27 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); + private ScmTransportProtocol scmTransportProtocol; + + @Before + public void bindScmProtocol() { + HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); + RepositoryManager repositoryManager = mock(RepositoryManager.class); + HookEventFacade hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory); + GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class); + scmTransportProtocol = new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler)); + + Transport.register(scmTransportProtocol); + + when(gitRepositoryHandler.getRepositoryId(any())).thenReturn("1"); + when(repositoryManager.get("1")).thenReturn(new sonia.scm.repository.Repository()); + } + + @After + public void unregisterScmProtocol() { + Transport.unregister(scmTransportProtocol); + } + @Test public void shouldDetectMergeableBranches() { GitMergeCommand command = createCommand(); @@ -77,6 +111,30 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n"); } + @Test + public void shouldNotMergeTwice() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + MergeCommandResult mergeCommandResult = command.merge(request); + + assertThat(mergeCommandResult.isSuccess()).isTrue(); + + Repository repository = createContext().open(); + ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); + + MergeCommandResult secondMergeCommandResult = command.merge(request); + + assertThat(secondMergeCommandResult.isSuccess()).isTrue(); + + ObjectId secondMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); + + assertThat(secondMergeCommit).isEqualTo(firstMergeCommit); + } + @Test public void shouldUseConfiguredCommitMessageTemplate() throws IOException, GitAPIException { GitMergeCommand command = createCommand(); @@ -111,11 +169,14 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } @Test - @SubjectAware(username = "admin", password = "secret") public void shouldTakeAuthorFromSubjectIfNotSet() throws IOException, GitAPIException { + SimplePrincipalCollection principals = new SimplePrincipalCollection(); + principals.add("admin", REALM); + principals.add( new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM); shiro.setSubject( new Subject.Builder() - .principals(new SimplePrincipalCollection(new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM)) + .principals(principals) + .authenticated(true) .buildSubject()); GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); @@ -133,6 +194,32 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det"); } + @Test + public void shouldMergeIntoNotDefaultBranch() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + request.setTargetBranch("mergeable"); + request.setBranchToMerge("master"); + + MergeCommandResult mergeCommandResult = command.merge(request); + + Repository repository = createContext().open(); + assertThat(mergeCommandResult.isSuccess()).isTrue(); + + Iterable commits = new Git(repository).log().add(repository.resolve("mergeable")).setMaxCount(1).call(); + RevCommit mergeCommit = commits.iterator().next(); + PersonIdent mergeAuthor = mergeCommit.getAuthorIdent(); + String message = mergeCommit.getFullMessage(); + assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently"); + assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det"); + assertThat(message).contains("master", "mergeable"); + // We expect the merge result of file b.txt here by looking up the sha hash of its content. + // If the file is missing (aka not merged correctly) this will throw a MissingObjectException: + byte[] contentOfFileB = repository.open(repository.resolve("9513e9c76e73f3e562fd8e4c909d0607113c77c6")).getBytes(); + assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n"); + } + private GitMergeCommand createCommand() { return new GitMergeCommand(createContext(), repository, new SimpleGitWorkdirFactory()); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java index 0c39a1deb0..da26ebaf20 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java @@ -2,14 +2,23 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ScmTransportProtocol; +import org.eclipse.jgit.transport.Transport; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.PreProcessorUtil; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.HookContextFactory; import java.io.File; import java.io.IOException; +import static com.google.inject.util.Providers.of; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -18,6 +27,14 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Before + public void bindScmProtocol() { + HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); + HookEventFacade hookEventFacade = new HookEventFacade(of(mock(RepositoryManager.class)), hookContextFactory); + GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class); + Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); + } + @Test public void emptyPoolShouldCreateNewWorkdir() throws IOException { SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder()); diff --git a/scm-plugins/scm-git-plugin/yarn.lock b/scm-plugins/scm-git-plugin/yarn.lock index 3514ed3f2c..234ed65102 100644 --- a/scm-plugins/scm-git-plugin/yarn.lock +++ b/scm-plugins/scm-git-plugin/yarn.lock @@ -707,9 +707,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index dbca702070..0638a464de 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -9,6 +9,6 @@ "@scm-manager/ui-extensions": "^0.1.1" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" } } diff --git a/scm-plugins/scm-hg-plugin/yarn.lock b/scm-plugins/scm-hg-plugin/yarn.lock index a211aa0ca1..0666ef408d 100644 --- a/scm-plugins/scm-hg-plugin/yarn.lock +++ b/scm-plugins/scm-hg-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index 41f1c88a18..e51f3b9bfd 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -9,6 +9,6 @@ "@scm-manager/ui-extensions": "^0.1.1" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" } } diff --git a/scm-plugins/scm-svn-plugin/yarn.lock b/scm-plugins/scm-svn-plugin/yarn.lock index a211aa0ca1..0666ef408d 100644 --- a/scm-plugins/scm-svn-plugin/yarn.lock +++ b/scm-plugins/scm-svn-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java index 2c5641bfd1..2180afdca2 100644 --- a/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java @@ -42,8 +42,20 @@ package sonia.scm.store; */ public class InMemoryConfigurationStoreFactory implements ConfigurationStoreFactory { + private ConfigurationStore store; + + public InMemoryConfigurationStoreFactory() { + } + + public InMemoryConfigurationStoreFactory(ConfigurationStore store) { + this.store = store; + } + @Override public ConfigurationStore getStore(TypedStoreParameters storeParameters) { + if (store != null) { + return store; + } return new InMemoryConfigurationStore<>(); } } diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java new file mode 100644 index 0000000000..06198d89bf --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java @@ -0,0 +1,53 @@ +package sonia.scm.store; + +import sonia.scm.security.KeyGenerator; +import sonia.scm.security.UUIDKeyGenerator; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * In memory store implementation of {@link DataStore}. + * + * @author Sebastian Sdorra + * + * @param type of stored object + */ +public class InMemoryDataStore implements DataStore { + + private final Map store = new HashMap<>(); + private KeyGenerator generator = new UUIDKeyGenerator(); + + @Override + public String put(T item) { + String key = generator.createKey(); + store.put(key, item); + return key; + } + + @Override + public void put(String id, T item) { + store.put(id, item); + } + + @Override + public Map getAll() { + return Collections.unmodifiableMap(store); + } + + @Override + public void clear() { + store.clear(); + } + + @Override + public void remove(String id) { + store.remove(id); + } + + @Override + public T get(String id) { + return store.get(id); + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java new file mode 100644 index 0000000000..b0e95e9f9c --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java @@ -0,0 +1,26 @@ +package sonia.scm.store; + +/** + * In memory configuration store factory for testing purposes. + * + * @author Sebastian Sdorra + */ +public class InMemoryDataStoreFactory implements DataStoreFactory { + + private InMemoryDataStore store; + + public InMemoryDataStoreFactory() { + } + + public InMemoryDataStoreFactory(InMemoryDataStore store) { + this.store = store; + } + + @Override + public DataStore getStore(TypedStoreParameters storeParameters) { + if (store != null) { + return store; + } + return new InMemoryDataStore<>(); + } +} diff --git a/scm-ui-components/packages/ui-components/package.json b/scm-ui-components/packages/ui-components/package.json index 4a4b4dc82e..06e007e871 100644 --- a/scm-ui-components/packages/ui-components/package.json +++ b/scm-ui-components/packages/ui-components/package.json @@ -14,7 +14,7 @@ "eslint-fix": "eslint src --fix" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21", + "@scm-manager/ui-bundler": "^0.0.24", "create-index": "^2.3.0", "enzyme": "^3.5.0", "enzyme-adapter-react-16": "^1.3.1", @@ -34,7 +34,9 @@ "react-dom": "^16.5.2", "react-i18next": "^7.11.0", "react-jss": "^8.6.1", - "react-router-dom": "^4.3.1" + "react-router-dom": "^4.3.1", + "react-select": "^2.1.2", + "diff2html": "^2.5.0" }, "browserify": { "transform": [ diff --git a/scm-ui-components/packages/ui-components/src/Autocomplete.js b/scm-ui-components/packages/ui-components/src/Autocomplete.js new file mode 100644 index 0000000000..f3023e268b --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/Autocomplete.js @@ -0,0 +1,73 @@ +// @flow +import React from "react"; +import { AsyncCreatable } from "react-select"; +import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types"; +import LabelWithHelpIcon from "./forms/LabelWithHelpIcon"; + + +type Props = { + loadSuggestions: string => Promise, + valueSelected: SelectValue => void, + label: string, + helpText?: string, + value?: SelectValue, + placeholder: string, + loadingMessage: string, + noOptionsMessage: string +}; + + +type State = {}; + +class Autocomplete extends React.Component { + + + static defaultProps = { + placeholder: "Type here", + loadingMessage: "Loading...", + noOptionsMessage: "No suggestion available" + }; + + handleInputChange = (newValue: SelectValue) => { + this.props.valueSelected(newValue); + }; + + // We overwrite this to avoid running into a bug (https://github.com/JedWatson/react-select/issues/2944) + isValidNewOption = (inputValue: string, selectValue: SelectValue, selectOptions: SelectValue[]) => { + const isNotDuplicated = !selectOptions + .map(option => option.label) + .includes(inputValue); + const isNotEmpty = inputValue !== ""; + return isNotEmpty && isNotDuplicated; + }; + + render() { + const { label, helpText, value, placeholder, loadingMessage, noOptionsMessage, loadSuggestions } = this.props; + return ( +
+ +
+ loadingMessage} + noOptionsMessage={() => noOptionsMessage} + isValidNewOption={this.isValidNewOption} + onCreateOption={value => { + this.handleInputChange({ + label: value, + value: { id: value, displayName: value } + }); + }} + /> +
+
+ ); + } +} + + +export default Autocomplete; diff --git a/scm-ui-components/packages/ui-components/src/ErrorNotification.js b/scm-ui-components/packages/ui-components/src/ErrorNotification.js index 9ef3b58653..6645db5f60 100644 --- a/scm-ui-components/packages/ui-components/src/ErrorNotification.js +++ b/scm-ui-components/packages/ui-components/src/ErrorNotification.js @@ -2,6 +2,7 @@ import React from "react"; import { translate } from "react-i18next"; import Notification from "./Notification"; +import {UNAUTHORIZED_ERROR} from "./apiclient"; type Props = { t: string => string, @@ -9,16 +10,27 @@ type Props = { }; class ErrorNotification extends React.Component { + render() { const { t, error } = this.props; if (error) { - return ( - - {t("error-notification.prefix")}: {error.message} - - ); + if (error === UNAUTHORIZED_ERROR) { + return ( + + {t("error-notification.prefix")}: {t("error-notification.timeout")} + {" "} + {t("error-notification.loginLink")} + + ); + } else { + return ( + + {t("error-notification.prefix")}: {error.message} + + ); + } } - return ""; + return null; } } diff --git a/scm-ui-components/packages/ui-components/src/Paginator.test.js b/scm-ui-components/packages/ui-components/src/Paginator.test.js index 1d32e22faf..d32b4df702 100644 --- a/scm-ui-components/packages/ui-components/src/Paginator.test.js +++ b/scm-ui-components/packages/ui-components/src/Paginator.test.js @@ -1,12 +1,11 @@ // @flow import React from "react"; -import {mount, shallow} from "enzyme"; +import { mount, shallow } from "enzyme"; import "./tests/enzyme"; import "./tests/i18n"; import ReactRouterEnzymeContext from "react-router-enzyme-context"; import Paginator from "./Paginator"; -// TODO: Fix tests xdescribe("paginator rendering tests", () => { const options = new ReactRouterEnzymeContext(); diff --git a/scm-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js index 3fd90a2d7a..25a108877a 100644 --- a/scm-ui-components/packages/ui-components/src/apiclient.js +++ b/scm-ui-components/packages/ui-components/src/apiclient.js @@ -1,8 +1,9 @@ // @flow import {contextPath} from "./urls"; -export const NOT_FOUND_ERROR_MESSAGE = "not found"; -export const UNAUTHORIZED_ERROR_MESSAGE = "unauthorized"; +export const NOT_FOUND_ERROR = new Error("not found"); +export const UNAUTHORIZED_ERROR = new Error("unauthorized"); +export const CONFLICT_ERROR = new Error("conflict"); const fetchOptions: RequestOptions = { credentials: "same-origin", @@ -15,28 +16,19 @@ function handleStatusCode(response: Response) { if (!response.ok) { switch (response.status) { case 401: - return throwErrorWithMessage(response, UNAUTHORIZED_ERROR_MESSAGE); + throw UNAUTHORIZED_ERROR; case 404: - return throwErrorWithMessage(response, NOT_FOUND_ERROR_MESSAGE); + throw NOT_FOUND_ERROR; + case 409: + throw CONFLICT_ERROR; default: - return throwErrorWithMessage(response, "server returned status code " + response.status); + throw new Error("server returned status code " + response.status); } } return response; } -function throwErrorWithMessage(response: Response, message: string) { - return response.json().then( - json => { - throw Error(json.message); - }, - () => { - throw Error(message); - } - ); -} - export function createUrl(url: string) { if (url.includes("://")) { return url; diff --git a/scm-ui-components/packages/ui-components/src/avatar/Avatar.js b/scm-ui-components/packages/ui-components/src/avatar/Avatar.js new file mode 100644 index 0000000000..4108ae354b --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/avatar/Avatar.js @@ -0,0 +1,8 @@ +// @flow + +export type Person = { + name: string, + mail?: string +}; + +export const EXTENSION_POINT = "avatar.factory"; diff --git a/scm-ui/src/repos/components/changesets/AvatarImage.js b/scm-ui-components/packages/ui-components/src/avatar/AvatarImage.js similarity index 53% rename from scm-ui/src/repos/components/changesets/AvatarImage.js rename to scm-ui-components/packages/ui-components/src/avatar/AvatarImage.js index 6d730e87cd..930c172b0b 100644 --- a/scm-ui/src/repos/components/changesets/AvatarImage.js +++ b/scm-ui-components/packages/ui-components/src/avatar/AvatarImage.js @@ -1,26 +1,28 @@ //@flow import React from "react"; import {binder} from "@scm-manager/ui-extensions"; -import type {Changeset} from "@scm-manager/ui-types"; -import {Image} from "@scm-manager/ui-components"; +import {Image} from ".."; +import type { Person } from "./Avatar"; +import { EXTENSION_POINT } from "./Avatar"; + type Props = { - changeset: Changeset + person: Person }; class AvatarImage extends React.Component { render() { - const { changeset } = this.props; + const { person } = this.props; - const avatarFactory = binder.getExtension("changeset.avatar-factory"); + const avatarFactory = binder.getExtension(EXTENSION_POINT); if (avatarFactory) { - const avatar = avatarFactory(changeset); + const avatar = avatarFactory(person); return ( {changeset.author.name} ); } diff --git a/scm-ui/src/repos/components/changesets/AvatarWrapper.js b/scm-ui-components/packages/ui-components/src/avatar/AvatarWrapper.js similarity index 76% rename from scm-ui/src/repos/components/changesets/AvatarWrapper.js rename to scm-ui-components/packages/ui-components/src/avatar/AvatarWrapper.js index c014b33281..50f584f753 100644 --- a/scm-ui/src/repos/components/changesets/AvatarWrapper.js +++ b/scm-ui-components/packages/ui-components/src/avatar/AvatarWrapper.js @@ -1,6 +1,7 @@ //@flow import * as React from "react"; import {binder} from "@scm-manager/ui-extensions"; +import { EXTENSION_POINT } from "./Avatar"; type Props = { children: React.Node @@ -8,7 +9,7 @@ type Props = { class AvatarWrapper extends React.Component { render() { - if (binder.hasExtension("changeset.avatar-factory")) { + if (binder.hasExtension(EXTENSION_POINT)) { return <>{this.props.children}; } return null; diff --git a/scm-ui-components/packages/ui-components/src/avatar/index.js b/scm-ui-components/packages/ui-components/src/avatar/index.js new file mode 100644 index 0000000000..c1f09fcaec --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/avatar/index.js @@ -0,0 +1,4 @@ +// @flow + +export { default as AvatarWrapper } from "./AvatarWrapper"; +export { default as AvatarImage } from "./AvatarImage"; diff --git a/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js b/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js index 960fe7db21..1b2b37bb19 100644 --- a/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js +++ b/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js @@ -63,8 +63,9 @@ class ConfigurationBinder { // route for global configuration, passes the current repository to component - const RepoRoute = ({ url, repository }) => { - return this.route(url + to, ); + const RepoRoute = ({url, repository}) => { + const link = repository._links[linkName].href + return this.route(url + to, ); }; // bind config route to extension point diff --git a/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js b/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js new file mode 100644 index 0000000000..f3af79e658 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js @@ -0,0 +1,88 @@ +//@flow +import React from "react"; + +import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types"; +import Autocomplete from "../Autocomplete"; +import AddButton from "../buttons/AddButton"; + +type Props = { + addEntry: SelectValue => void, + disabled: boolean, + buttonLabel: string, + fieldLabel: string, + helpText?: string, + loadSuggestions: string => Promise, + placeholder?: string, + loadingMessage?: string, + noOptionsMessage?: string +}; + +type State = { + selectedValue?: SelectValue +}; + +class AutocompleteAddEntryToTableField extends React.Component { + constructor(props: Props) { + super(props); + this.state = { selectedValue: undefined }; + } + render() { + const { + disabled, + buttonLabel, + fieldLabel, + helpText, + loadSuggestions, + placeholder, + loadingMessage, + noOptionsMessage + } = this.props; + + const { selectedValue } = this.state; + return ( +
+ + + +
+ ); + } + + addButtonClicked = (event: Event) => { + event.preventDefault(); + this.appendEntry(); + }; + + appendEntry = () => { + const { selectedValue } = this.state; + if (!selectedValue) { + return; + } + // $FlowFixMe null is needed to clear the selection; undefined does not work + this.setState({ ...this.state, selectedValue: null }, () => + this.props.addEntry(selectedValue) + ); + }; + + handleAddEntryChange = (selection: SelectValue) => { + this.setState({ + ...this.state, + selectedValue: selection + }); + }; +} + +export default AutocompleteAddEntryToTableField; diff --git a/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js b/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js index 8a917828ec..c0e1dffb6a 100644 --- a/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js +++ b/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js @@ -1,6 +1,6 @@ //@flow import React from "react"; -import Help from '../Help'; +import Help from "../Help.js"; type Props = { label?: string, diff --git a/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js b/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js index 3dc59ad906..b0f53cddeb 100644 --- a/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js +++ b/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js @@ -11,7 +11,7 @@ type State = { passwordConfirmationFailed: boolean }; type Props = { - passwordChanged: string => void, + passwordChanged: (string, boolean) => void, passwordValidator?: string => boolean, // Context props t: string => string @@ -98,14 +98,12 @@ class PasswordConfirmation extends React.Component { ); }; + isValid = () => { + return this.state.passwordValid && !this.state.passwordConfirmationFailed + }; + propagateChange = () => { - if ( - this.state.password && - this.state.passwordValid && - !this.state.passwordConfirmationFailed - ) { - this.props.passwordChanged(this.state.password); - } + this.props.passwordChanged(this.state.password, this.isValid()); }; } diff --git a/scm-ui-components/packages/ui-components/src/forms/index.js b/scm-ui-components/packages/ui-components/src/forms/index.js index b1cf06740f..714b9b3301 100644 --- a/scm-ui-components/packages/ui-components/src/forms/index.js +++ b/scm-ui-components/packages/ui-components/src/forms/index.js @@ -1,6 +1,7 @@ // @create-index export { default as AddEntryToTableField } from "./AddEntryToTableField.js"; +export { default as AutocompleteAddEntryToTableField } from "./AutocompleteAddEntryToTableField.js"; export { default as Checkbox } from "./Checkbox.js"; export { default as InputField } from "./InputField.js"; export { default as Select } from "./Select.js"; diff --git a/scm-ui-components/packages/ui-components/src/index.js b/scm-ui-components/packages/ui-components/src/index.js index 0900af2190..91c41a9d25 100644 --- a/scm-ui-components/packages/ui-components/src/index.js +++ b/scm-ui-components/packages/ui-components/src/index.js @@ -23,12 +23,15 @@ export { default as Help } from "./Help"; export { default as HelpIcon } from "./HelpIcon"; export { default as Tooltip } from "./Tooltip"; export { getPageFromMatch } from "./urls"; +export { default as Autocomplete} from "./Autocomplete"; -export { apiClient, NOT_FOUND_ERROR_MESSAGE, UNAUTHORIZED_ERROR_MESSAGE } from "./apiclient.js"; +export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR, CONFLICT_ERROR } from "./apiclient.js"; +export * from "./avatar"; export * from "./buttons"; export * from "./config"; export * from "./forms"; export * from "./layout"; export * from "./modals"; export * from "./navigation"; +export * from "./repos"; diff --git a/scm-ui-components/packages/ui-components/src/repos/Diff.js b/scm-ui-components/packages/ui-components/src/repos/Diff.js new file mode 100644 index 0000000000..0b0b31a3d4 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/Diff.js @@ -0,0 +1,36 @@ +//@flow +import React from "react"; +import { Diff2Html } from "diff2html"; + +type Props = { + diff: string, + sideBySide: boolean +}; + +class Diff extends React.Component { + + static defaultProps = { + sideBySide: false + }; + + render() { + const { diff, sideBySide } = this.props; + + const options = { + inputFormat: "diff", + outputFormat: sideBySide ? "side-by-side" : "line-by-line", + showFiles: false, + matching: "lines" + }; + + const outputHtml = Diff2Html.getPrettyHtml(diff, options); + + return ( + // eslint-disable-next-line react/no-danger +
+ ); + } + +} + +export default Diff; diff --git a/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js b/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js new file mode 100644 index 0000000000..5f6330f0e5 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js @@ -0,0 +1,64 @@ +//@flow +import React from "react"; +import { apiClient } from "../apiclient"; +import ErrorNotification from "../ErrorNotification"; +import Loading from "../Loading"; +import Diff from "./Diff"; + +type Props = { + url: string, + sideBySide: boolean +}; + +type State = { + diff?: string, + loading: boolean, + error?: Error +}; + +class LoadingDiff extends React.Component { + + static defaultProps = { + sideBySide: false + }; + + constructor(props: Props) { + super(props); + this.state = { + loading: true + }; + } + + componentDidMount() { + const { url } = this.props; + apiClient + .get(url) + .then(response => response.text()) + .then(text => { + this.setState({ + loading: false, + diff: text + }); + }) + .catch(error => { + this.setState({ + loading: false, + error + }); + }); + } + + render() { + const { diff, loading, error } = this.state; + if (error) { + return ; + } else if (loading || !diff) { + return ; + } else { + return ; + } + } + +} + +export default LoadingDiff; diff --git a/scm-ui/src/repos/components/changesets/ChangesetAuthor.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js similarity index 79% rename from scm-ui/src/repos/components/changesets/ChangesetAuthor.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js index 778d4b5073..5bb6437575 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetAuthor.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js @@ -1,13 +1,12 @@ //@flow - import React from "react"; -import type { Changeset } from "@scm-manager/ui-types"; +import type {Changeset} from "@scm-manager/ui-types"; type Props = { changeset: Changeset }; -export default class ChangesetAuthor extends React.Component { +class ChangesetAuthor extends React.Component { render() { const { changeset } = this.props; if (!changeset.author) { @@ -35,3 +34,5 @@ export default class ChangesetAuthor extends React.Component { } } } + +export default ChangesetAuthor; diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetDiff.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetDiff.js new file mode 100644 index 0000000000..857ff8c827 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetDiff.js @@ -0,0 +1,37 @@ +//@flow +import React from "react"; +import type { Changeset } from "@scm-manager/ui-types"; +import LoadingDiff from "../LoadingDiff"; +import Notification from "../../Notification"; +import {translate} from "react-i18next"; + +type Props = { + changeset: Changeset, + + // context props + t: string => string +}; + +class ChangesetDiff extends React.Component { + + isDiffSupported(changeset: Changeset) { + return !!changeset._links.diff; + } + + createUrl(changeset: Changeset) { + return changeset._links.diff.href + "?format=GIT"; + } + + render() { + const { changeset, t } = this.props; + if (!this.isDiffSupported(changeset)) { + return {t("changesets.diff.not-supported")}; + } else { + const url = this.createUrl(changeset); + return ; + } + } + +} + +export default translate("repos")(ChangesetDiff); diff --git a/scm-ui/src/repos/components/changesets/ChangesetId.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetId.js similarity index 100% rename from scm-ui/src/repos/components/changesets/ChangesetId.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetId.js diff --git a/scm-ui/src/repos/components/changesets/ChangesetList.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetList.js similarity index 85% rename from scm-ui/src/repos/components/changesets/ChangesetList.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetList.js index 860b7ba8b0..74ec816369 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetList.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetList.js @@ -1,8 +1,8 @@ // @flow import ChangesetRow from "./ChangesetRow"; import React from "react"; + import type { Changeset, Repository } from "@scm-manager/ui-types"; -import classNames from "classnames"; type Props = { repository: Repository, @@ -21,7 +21,7 @@ class ChangesetList extends React.Component { /> ); }); - return
{content}
; + return
{content}
; } } diff --git a/scm-ui/src/repos/components/changesets/ChangesetRow.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js similarity index 86% rename from scm-ui/src/repos/components/changesets/ChangesetRow.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js index 0215abedd1..ef9de9bfe5 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetRow.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js @@ -1,17 +1,17 @@ //@flow import React from "react"; -import type {Changeset, Repository, Tag} from "@scm-manager/ui-types"; +import type { Changeset, Repository, Tag } from "@scm-manager/ui-types"; + import classNames from "classnames"; import {Interpolate, translate} from "react-i18next"; import ChangesetId from "./ChangesetId"; import injectSheet from "react-jss"; -import {DateFromNow} from "@scm-manager/ui-components"; +import {DateFromNow} from "../.."; import ChangesetAuthor from "./ChangesetAuthor"; import ChangesetTag from "./ChangesetTag"; -import {compose} from "redux"; + import {parseDescription} from "./changesets"; -import AvatarWrapper from "./AvatarWrapper"; -import AvatarImage from "./AvatarImage"; +import {AvatarWrapper, AvatarImage} from "../../avatar"; const styles = { pointer: { @@ -56,7 +56,7 @@ class ChangesetRow extends React.Component {

- +

@@ -95,7 +95,4 @@ class ChangesetRow extends React.Component { }; } -export default compose( - injectSheet(styles), - translate("repos") -)(ChangesetRow); +export default injectSheet(styles)(translate("repos")(ChangesetRow)); diff --git a/scm-ui/src/repos/components/changesets/ChangesetTag.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTag.js similarity index 100% rename from scm-ui/src/repos/components/changesets/ChangesetTag.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTag.js diff --git a/scm-ui/src/repos/components/changesets/changesets.js b/scm-ui-components/packages/ui-components/src/repos/changesets/changesets.js similarity index 100% rename from scm-ui/src/repos/components/changesets/changesets.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/changesets.js diff --git a/scm-ui/src/repos/components/changesets/changesets.test.js b/scm-ui-components/packages/ui-components/src/repos/changesets/changesets.test.js similarity index 100% rename from scm-ui/src/repos/components/changesets/changesets.test.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/changesets.test.js diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/index.js b/scm-ui-components/packages/ui-components/src/repos/changesets/index.js new file mode 100644 index 0000000000..0e7a5e533d --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/index.js @@ -0,0 +1,10 @@ +// @flow +import * as changesets from "./changesets"; +export { changesets }; + +export { default as ChangesetAuthor } from "./ChangesetAuthor"; +export { default as ChangesetId } from "./ChangesetId"; +export { default as ChangesetList } from "./ChangesetList"; +export { default as ChangesetRow } from "./ChangesetRow"; +export { default as ChangesetTag } from "./ChangesetTag"; +export { default as ChangesetDiff } from "./ChangesetDiff"; diff --git a/scm-ui-components/packages/ui-components/src/repos/index.js b/scm-ui-components/packages/ui-components/src/repos/index.js new file mode 100644 index 0000000000..9ebd1e9c55 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/index.js @@ -0,0 +1,5 @@ +// @flow + +export * from "./changesets"; +export { default as Diff } from "./Diff"; +export { default as LoadingDiff } from "./LoadingDiff"; diff --git a/scm-ui-components/packages/ui-components/yarn.lock b/scm-ui-components/packages/ui-components/yarn.lock index 94816787ec..062bb75ab1 100644 --- a/scm-ui-components/packages/ui-components/yarn.lock +++ b/scm-ui-components/packages/ui-components/yarn.lock @@ -576,6 +576,12 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" +"@babel/runtime@^7.1.2": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39" + dependencies: + regenerator-runtime "^0.12.0" + "@babel/template@^7.1.0", "@babel/template@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" @@ -606,6 +612,46 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + "@gulp-sourcemaps/identity-map@1.X": version "1.0.2" resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9" @@ -641,9 +687,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -1121,6 +1167,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1134,6 +1197,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-macros@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28" + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -1633,12 +1707,24 @@ cached-path-relative@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + dependencies: + callsites "^2.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" dependencies: callsites "^0.2.0" +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -1782,7 +1868,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.6: +classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -1966,7 +2052,7 @@ contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" -convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" dependencies: @@ -1992,6 +2078,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04" + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + coveralls@^2.11.3: version "2.13.3" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" @@ -2009,6 +2104,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2122,6 +2229,10 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" @@ -2333,7 +2444,16 @@ dev-ip@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" -diff@^3.2.0: +diff2html@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.5.0.tgz#2d16f1a8f115354733b16b0264a594fa7db98aa2" + dependencies: + diff "^3.5.0" + hogan.js "^3.0.2" + lodash "^4.17.11" + whatwg-fetch "^3.0.0" + +diff@^3.2.0, diff@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -2362,6 +2482,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-helpers@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" @@ -2466,6 +2592,13 @@ emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2561,7 +2694,7 @@ enzyme@^3.5.0: rst-selector-parser "^2.2.3" string.prototype.trim "^1.1.2" -error-ex@^1.2.0: +error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" dependencies: @@ -3070,6 +3203,10 @@ find-node-modules@^1.0.4: findup-sync "0.4.2" merge "^1.2.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3730,6 +3867,13 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hogan.js@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/hogan.js/-/hogan.js-3.0.2.tgz#4cd9e1abd4294146e7679e41d7898732b02c7bfd" + dependencies: + mkdirp "0.3.0" + nopt "1.0.10" + hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" @@ -3862,6 +4006,13 @@ immutable@^3: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" @@ -4038,6 +4189,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" @@ -4689,7 +4844,7 @@ js-yaml@3.6.1: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.12.0, js-yaml@^3.7.0: +js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -4743,6 +4898,10 @@ jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + json-schema-traverse@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" @@ -5115,7 +5274,7 @@ lodash.templatesettings@^3.0.0: lodash._reinterpolate "^3.0.0" lodash.escape "^3.0.0" -lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: +lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -5127,7 +5286,7 @@ log-driver@1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" dependencies: @@ -5216,6 +5375,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" @@ -5371,6 +5534,10 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" +mkdirp@0.3.0: + version "0.3.0" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" + "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: version "0.5.1" resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -5549,6 +5716,12 @@ nomnom@~1.6.2: colors "0.5.x" underscore "~1.4.4" +nopt@1.0.10, nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + dependencies: + abbrev "1" + nopt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" @@ -5907,6 +6080,13 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + parse-passwd@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" @@ -6279,6 +6459,12 @@ react-i18next@^7.11.0: html-parse-stringify2 "2.0.1" prop-types "^15.6.0" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-is@^16.5.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" @@ -6293,6 +6479,10 @@ react-jss@^8.6.1: prop-types "^15.6.0" theming "^1.3.0" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-router-dom@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" @@ -6323,6 +6513,18 @@ react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-test-renderer@^16.0.0-0: version "16.5.2" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.5.2.tgz#92e9d2c6f763b9821b2e0b22f994ee675068b5ae" @@ -6332,6 +6534,15 @@ react-test-renderer@^16.0.0-0: react-is "^16.5.2" schedule "^0.5.0" +react-transition-group@^2.2.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874" + dependencies: + dom-helpers "^3.3.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.4.2: version "16.6.0" resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246" @@ -6466,6 +6677,10 @@ regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -6659,7 +6874,7 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: @@ -7033,6 +7248,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + sparkles@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c" @@ -7243,6 +7462,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -7442,6 +7669,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" @@ -7827,6 +8060,10 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.24" +whatwg-fetch@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + whatwg-mimetype@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz#a3d58ef10b76009b042d03e25591ece89b88d171" diff --git a/scm-ui-components/packages/ui-types/package.json b/scm-ui-components/packages/ui-types/package.json index 78452c2ef5..4d87265379 100644 --- a/scm-ui-components/packages/ui-types/package.json +++ b/scm-ui-components/packages/ui-types/package.json @@ -14,7 +14,7 @@ "check": "flow check" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" }, "browserify": { "transform": [ @@ -33,4 +33,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/scm-ui-components/packages/ui-types/src/Autocomplete.js b/scm-ui-components/packages/ui-types/src/Autocomplete.js new file mode 100644 index 0000000000..407108c115 --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/Autocomplete.js @@ -0,0 +1,10 @@ +// @flow +export type AutocompleteObject = { + id: string, + displayName: string +}; + +export type SelectValue = { + value: AutocompleteObject, + label: string +}; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index 883272b4d4..cf739f747d 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -22,3 +22,5 @@ export type { IndexResources } from "./IndexResources"; export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export type { SubRepository, File } from "./Sources"; + +export type { SelectValue, AutocompleteObject } from "./Autocomplete"; diff --git a/scm-ui-components/packages/ui-types/yarn.lock b/scm-ui-components/packages/ui-types/yarn.lock index fe2df2f76a..ee367343ee 100644 --- a/scm-ui-components/packages/ui-types/yarn.lock +++ b/scm-ui-components/packages/ui-types/yarn.lock @@ -707,9 +707,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-ui/package.json b/scm-ui/package.json index d80ee6571e..2f68b75317 100644 --- a/scm-ui/package.json +++ b/scm-ui/package.json @@ -11,7 +11,7 @@ "bulma": "^0.7.1", "bulma-tooltip": "^2.0.2", "classnames": "^2.2.5", - "diff2html": "^2.4.0", + "diff2html": "^2.5.0", "font-awesome": "^4.7.0", "history": "^4.7.2", "i18next": "^11.4.0", @@ -21,13 +21,13 @@ "node-sass": "^4.9.3", "postcss-easy-import": "^3.0.0", "react": "^16.4.2", - "react-diff-view": "^1.7.0", "react-dom": "^16.4.2", "react-i18next": "^7.9.0", "react-jss": "^8.6.0", "react-redux": "^5.0.7", "react-router-dom": "^4.3.1", "react-router-redux": "^5.0.0-alpha.9", + "react-select": "^2.1.2", "react-syntax-highlighter": "^9.0.1", "redux": "^4.0.0", "redux-devtools-extension": "^2.13.5", @@ -51,7 +51,7 @@ "pre-commit": "jest && flow && eslint src" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21", + "@scm-manager/ui-bundler": "^0.0.24", "concat": "^1.0.3", "copyfiles": "^2.0.0", "enzyme": "^3.3.0", diff --git a/scm-ui/public/locales/en/commons.json b/scm-ui/public/locales/en/commons.json index 47a8735e5b..2908a38a4f 100644 --- a/scm-ui/public/locales/en/commons.json +++ b/scm-ui/public/locales/en/commons.json @@ -20,7 +20,9 @@ } }, "error-notification": { - "prefix": "Error" + "prefix": "Error", + "loginLink": "You can login here again.", + "timeout": "The session has expired." }, "loading": { "alt": "Loading ..." diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index daa2cc651a..f1ebb95e18 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -39,7 +39,13 @@ "label": "Add member", "error": "Invalid member name" }, - "group-form": { + "add-member-autocomplete": { + "placeholder": "Enter member", + "loading": "Loading...", + "no-options": "No suggestion available" + }, + +"group-form": { "submit": "Submit", "name-error": "Group name is invalid", "description-error": "Description is invalid", diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 7e421e7b99..d6cdaa1d8d 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -66,6 +66,9 @@ } }, "changesets": { + "diff": { + "not-supported": "Diff of changesets is not supported by the type of repository" + }, "error-title": "Error", "error-subtitle": "Could not fetch changesets", "changeset": { @@ -84,11 +87,14 @@ "label": "Branches" }, "permission": { + "user": "User", + "group": "Group", "error-title": "Error", "error-subtitle": "Unknown permissions error", "name": "User or Group", "type": "Type", "group-permission": "Group Permission", + "user-permission": "User Permission", "edit-permission": { "delete-button": "Delete", "save-button": "Save Changes" @@ -111,6 +117,13 @@ "groupPermissionHelpText": "States if a permission is a group permission.", "nameHelpText": "Manage permissions for a specific user or group", "typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions" + }, + "autocomplete": { + "no-group-options": "No group suggestion available", + "group-placeholder": "Enter group", + "no-user-options": "No user suggestion available", + "user-placeholder": "Enter user", + "loading": "Loading..." } }, "help": { diff --git a/scm-ui/src/config/modules/config.js b/scm-ui/src/config/modules/config.js index 352afefb70..2d14fcfea6 100644 --- a/scm-ui/src/config/modules/config.js +++ b/scm-ui/src/config/modules/config.js @@ -32,9 +32,8 @@ export function fetchConfig(link: string) { .then(data => { dispatch(fetchConfigSuccess(data)); }) - .catch(cause => { - const error = new Error(`could not fetch config: ${cause.message}`); - dispatch(fetchConfigFailure(error)); + .catch(err => { + dispatch(fetchConfigFailure(err)); }); }; } @@ -73,13 +72,8 @@ export function modifyConfig(config: Config, callback?: () => void) { callback(); } }) - .catch(cause => { - dispatch( - modifyConfigFailure( - config, - new Error(`could not modify config: ${cause.message}`) - ) - ); + .catch(err => { + dispatch(modifyConfigFailure(config, err)); }); }; } diff --git a/scm-ui/src/containers/ChangeUserPassword.js b/scm-ui/src/containers/ChangeUserPassword.js index 6fa38d470f..28a7af588a 100644 --- a/scm-ui/src/containers/ChangeUserPassword.js +++ b/scm-ui/src/containers/ChangeUserPassword.js @@ -21,7 +21,8 @@ type State = { password: string, loading: boolean, error?: Error, - passwordChanged: boolean + passwordChanged: boolean, + passwordValid: boolean }; class ChangeUserPassword extends React.Component { @@ -35,7 +36,8 @@ class ChangeUserPassword extends React.Component { passwordConfirmationError: false, validatePasswordError: false, validatePassword: "", - passwordChanged: false + passwordChanged: false, + passwordValid: false }; } @@ -83,6 +85,10 @@ class ChangeUserPassword extends React.Component { } }; + isValid = () => { + return this.state.oldPassword && this.state.passwordValid; + }; + render() { const { t } = this.props; const { loading, passwordChanged, error } = this.state; @@ -118,7 +124,7 @@ class ChangeUserPassword extends React.Component { key={this.state.passwordChanged ? "changed" : "unchanged"} /> @@ -126,8 +132,8 @@ class ChangeUserPassword extends React.Component { ); } - passwordChanged = (password: string) => { - this.setState({ ...this.state, password }); + passwordChanged = (password: string, passwordValid: boolean) => { + this.setState({ ...this.state, password, passwordValid: (!!password && passwordValid) }); }; onClose = () => { diff --git a/scm-ui/src/containers/ProfileInfo.js b/scm-ui/src/containers/ProfileInfo.js index 7baf2a9921..9c4a5a9323 100644 --- a/scm-ui/src/containers/ProfileInfo.js +++ b/scm-ui/src/containers/ProfileInfo.js @@ -1,8 +1,7 @@ // @flow import React from "react"; -import AvatarWrapper from "../repos/components/changesets/AvatarWrapper"; import type { Me } from "@scm-manager/ui-types"; -import { MailLink } from "@scm-manager/ui-components"; +import { MailLink, AvatarWrapper, AvatarImage } from "@scm-manager/ui-components"; import { compose } from "redux"; import { translate } from "react-i18next"; @@ -18,37 +17,35 @@ class ProfileInfo extends React.Component { render() { const { me, t } = this.props; return ( - <> +
-
-
-

- { - // TODO: add avatar - } -

-
-
+
+

+ +

+
- - - - - - - - - - - - - - - -
{t("profile.username")}{me.name}
{t("profile.displayName")}{me.displayName}
{t("profile.mail")} - -
- +
+ + + + + + + + + + + + + + + +
{t("profile.username")}{me.name}
{t("profile.displayName")}{me.displayName}
{t("profile.mail")} + +
+
+
); } } diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 4958fbf0fa..589914021c 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -2,12 +2,12 @@ import React from "react"; import { translate } from "react-i18next"; import { + AutocompleteAddEntryToTableField, InputField, SubmitButton, - Textarea, - AddEntryToTableField + Textarea } from "@scm-manager/ui-components"; -import type { Group } from "@scm-manager/ui-types"; +import type { Group, SelectValue } from "@scm-manager/ui-types"; import * as validator from "./groupValidation"; import MemberNameTable from "./MemberNameTable"; @@ -16,7 +16,8 @@ type Props = { t: string => string, submitForm: Group => void, loading?: boolean, - group?: Group + group?: Group, + loadUserSuggestions: string => any }; type State = { @@ -70,7 +71,7 @@ class GroupForm extends React.Component { render() { const { t, loading } = this.props; - const group = this.state.group; + const { group } = this.state; let nameField = null; if (!this.props.group) { nameField = ( @@ -97,15 +98,20 @@ class GroupForm extends React.Component { helpText={t("group-form.help.descriptionHelpText")} /> - { }); }; - addMember = (membername: string) => { - if (this.isMember(membername)) { + addMember = (value: SelectValue) => { + if (this.isMember(value.value.id)) { return; } @@ -135,7 +141,7 @@ class GroupForm extends React.Component { ...this.state, group: { ...this.state.group, - members: [...this.state.group.members, membername] + members: [...this.state.group.members, value.value.id] } }); }; diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 9b13ac0309..c19f6156d1 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -13,7 +13,10 @@ import { } from "../modules/groups"; import type { Group } from "@scm-manager/ui-types"; import type { History } from "history"; -import { getGroupsLink } from "../../modules/indexResource"; +import { + getGroupsLink, + getUserAutoCompleteLink +} from "../../modules/indexResource"; type Props = { t: string => string, @@ -22,7 +25,8 @@ type Props = { loading?: boolean, error?: Error, resetForm: () => void, - createLink: string + createLink: string, + autocompleteLink: string }; type State = {}; @@ -31,6 +35,7 @@ class AddGroup extends React.Component { componentDidMount() { this.props.resetForm(); } + render() { const { t, loading, error } = this.props; return ( @@ -43,12 +48,26 @@ class AddGroup extends React.Component { this.createGroup(group)} loading={loading} + loadUserSuggestions={this.loadUserAutocompletion} />
); } + loadUserAutocompletion = (inputValue: string) => { + const url = this.props.autocompleteLink + "?q="; + return fetch(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; groupCreated = () => { this.props.history.push("/groups"); }; @@ -71,10 +90,12 @@ const mapStateToProps = state => { const loading = isCreateGroupPending(state); const error = getCreateGroupFailure(state); const createLink = getGroupsLink(state); + const autocompleteLink = getUserAutoCompleteLink(state); return { createLink, loading, - error + error, + autocompleteLink }; }; diff --git a/scm-ui/src/groups/containers/EditGroup.js b/scm-ui/src/groups/containers/EditGroup.js index ac6e737dac..223ea1eef6 100644 --- a/scm-ui/src/groups/containers/EditGroup.js +++ b/scm-ui/src/groups/containers/EditGroup.js @@ -3,21 +3,23 @@ import React from "react"; import { connect } from "react-redux"; import GroupForm from "../components/GroupForm"; import { - modifyGroup, - modifyGroupReset, + getModifyGroupFailure, isModifyGroupPending, - getModifyGroupFailure + modifyGroup, + modifyGroupReset } from "../modules/groups"; import type { History } from "history"; import { withRouter } from "react-router-dom"; import type { Group } from "@scm-manager/ui-types"; import { ErrorNotification } from "@scm-manager/ui-components"; +import { getUserAutoCompleteLink } from "../../modules/indexResource"; type Props = { group: Group, modifyGroup: (group: Group, callback?: () => void) => void, modifyGroupReset: Group => void, fetchGroup: (name: string) => void, + autocompleteLink: string, history: History, loading?: boolean, error: Error @@ -37,6 +39,20 @@ class EditGroup extends React.Component { this.props.modifyGroup(group, this.groupModified(group)); }; + loadUserAutocompletion = (inputValue: string) => { + const url = this.props.autocompleteLink + "?q="; + return fetch(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; + render() { const { group, loading, error } = this.props; return ( @@ -48,6 +64,7 @@ class EditGroup extends React.Component { this.modifyGroup(group); }} loading={loading} + loadUserSuggestions={this.loadUserAutocompletion} /> ); @@ -57,9 +74,11 @@ class EditGroup extends React.Component { const mapStateToProps = (state, ownProps) => { const loading = isModifyGroupPending(state, ownProps.group.name); const error = getModifyGroupFailure(state, ownProps.group.name); + const autocompleteLink = getUserAutoCompleteLink(state); return { loading, - error + error, + autocompleteLink }; }; diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 165648edaa..483b5b3798 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -54,9 +54,8 @@ export function fetchGroupsByLink(link: string) { .then(data => { dispatch(fetchGroupsSuccess(data)); }) - .catch(cause => { - const error = new Error(`could not fetch groups: ${cause.message}`); - dispatch(fetchGroupsFailure(link, error)); + .catch(err => { + dispatch(fetchGroupsFailure(link, err)); }); }; } @@ -105,9 +104,8 @@ function fetchGroup(link: string, name: string) { .then(data => { dispatch(fetchGroupSuccess(data)); }) - .catch(cause => { - const error = new Error(`could not fetch group: ${cause.message}`); - dispatch(fetchGroupFailure(name, error)); + .catch(err => { + dispatch(fetchGroupFailure(name, err)); }); }; } @@ -151,10 +149,10 @@ export function createGroup(link: string, group: Group, callback?: () => void) { callback(); } }) - .catch(error => { + .catch(err => { dispatch( createGroupFailure( - new Error(`Failed to create group ${group.name}: ${error.message}`) + err ) ); }); @@ -201,11 +199,11 @@ export function modifyGroup(group: Group, callback?: () => void) { .then(() => { dispatch(fetchGroupByLink(group)); }) - .catch(cause => { + .catch(err => { dispatch( modifyGroupFailure( group, - new Error(`could not modify group ${group.name}: ${cause.message}`) + err ) ); }); @@ -259,11 +257,8 @@ export function deleteGroup(group: Group, callback?: () => void) { callback(); } }) - .catch(cause => { - const error = new Error( - `could not delete group ${group.name}: ${cause.message}` - ); - dispatch(deleteGroupFailure(group, error)); + .catch(err => { + dispatch(deleteGroupFailure(group, err)); }); }; } diff --git a/scm-ui/src/modules/auth.js b/scm-ui/src/modules/auth.js index 489f701a74..e9bccb8fbc 100644 --- a/scm-ui/src/modules/auth.js +++ b/scm-ui/src/modules/auth.js @@ -2,10 +2,7 @@ import type { Me } from "@scm-manager/ui-types"; import * as types from "./types"; -import { - apiClient, - UNAUTHORIZED_ERROR_MESSAGE -} from "@scm-manager/ui-components"; +import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components"; import { isPending } from "./pending"; import { getFailure } from "./failure"; import { @@ -190,7 +187,7 @@ export const fetchMe = (link: string) => { dispatch(fetchMeSuccess(me)); }) .catch((error: Error) => { - if (error.message === UNAUTHORIZED_ERROR_MESSAGE) { + if (error === UNAUTHORIZED_ERROR) { dispatch(fetchMeUnauthenticated()); } else { dispatch(fetchMeFailure(error)); diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js index 98dd9848dc..df55c63756 100644 --- a/scm-ui/src/modules/indexResource.js +++ b/scm-ui/src/modules/indexResource.js @@ -2,7 +2,7 @@ import * as types from "./types"; import { apiClient } from "@scm-manager/ui-components"; -import type { Action, IndexResources } from "@scm-manager/ui-types"; +import type { Action, IndexResources, Link } from "@scm-manager/ui-types"; import { isPending } from "./pending"; import { getFailure } from "./failure"; @@ -100,6 +100,13 @@ export function getLink(state: Object, name: string) { } } +export function getLinkCollection(state: Object, name: string): Link[] { + if (state.indexResources.links && state.indexResources.links[name]) { + return state.indexResources.links[name]; + } + return []; +} + export function getUiPluginsLink(state: Object) { return getLink(state, "uiPlugins"); } @@ -143,3 +150,23 @@ export function getGitConfigLink(state: Object) { export function getSvnConfigLink(state: Object) { return getLink(state, "svnConfig"); } + +export function getUserAutoCompleteLink(state: Object): string { + const link = getLinkCollection(state, "autocomplete").find( + i => i.name === "users" + ); + if (link) { + return link.href; + } + return ""; +} + +export function getGroupAutoCompleteLink(state: Object): string { + const link = getLinkCollection(state, "autocomplete").find( + i => i.name === "groups" + ); + if (link) { + return link.href; + } + return ""; +} diff --git a/scm-ui/src/modules/indexResource.test.js b/scm-ui/src/modules/indexResource.test.js index 2199da8290..ed3b7cb4d5 100644 --- a/scm-ui/src/modules/indexResource.test.js +++ b/scm-ui/src/modules/indexResource.test.js @@ -20,7 +20,11 @@ import reducer, { getHgConfigLink, getGitConfigLink, getSvnConfigLink, - getLinks, getGroupsLink + getLinks, + getGroupsLink, + getLinkCollection, + getUserAutoCompleteLink, + getGroupAutoCompleteLink } from "./indexResource"; const indexResourcesUnauthenticated = { @@ -73,354 +77,404 @@ const indexResourcesAuthenticated = { }, svnConfig: { href: "http://localhost:8081/scm/api/v2/config/svn" - } + }, + autocomplete: [ + { + href: "http://localhost:8081/scm/api/v2/autocomplete/users", + name: "users" + }, + { + href: "http://localhost:8081/scm/api/v2/autocomplete/groups", + name: "groups" + } + ] } }; -describe("fetch index resource", () => { - const index_url = "/api/v2/"; - const mockStore = configureMockStore([thunk]); +describe("index resource", () => { + describe("fetch index resource", () => { + const index_url = "/api/v2/"; + const mockStore = configureMockStore([thunk]); - afterEach(() => { - fetchMock.reset(); - fetchMock.restore(); - }); + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); - it("should successfully fetch index resources when unauthenticated", () => { - fetchMock.getOnce(index_url, indexResourcesUnauthenticated); + it("should successfully fetch index resources when unauthenticated", () => { + fetchMock.getOnce(index_url, indexResourcesUnauthenticated); - const expectedActions = [ - { type: FETCH_INDEXRESOURCES_PENDING }, - { - type: FETCH_INDEXRESOURCES_SUCCESS, - payload: indexResourcesUnauthenticated - } - ]; + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesUnauthenticated + } + ]; - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - expect(store.getActions()).toEqual(expectedActions); + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should successfully fetch index resources when authenticated", () => { + fetchMock.getOnce(index_url, indexResourcesAuthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesAuthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { + fetchMock.getOnce(index_url, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); + expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); }); }); - it("should successfully fetch index resources when authenticated", () => { - fetchMock.getOnce(index_url, indexResourcesAuthenticated); + describe("index resources reducer", () => { + it("should return empty object, if state and action is undefined", () => { + expect(reducer()).toEqual({}); + }); - const expectedActions = [ - { type: FETCH_INDEXRESOURCES_PENDING }, - { - type: FETCH_INDEXRESOURCES_SUCCESS, - payload: indexResourcesAuthenticated - } - ]; + it("should return the same state, if the action is undefined", () => { + const state = { x: true }; + expect(reducer(state)).toBe(state); + }); - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - expect(store.getActions()).toEqual(expectedActions); + it("should return the same state, if the action is unknown to the reducer", () => { + const state = { x: true }; + expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); + }); + + it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { + const newState = reducer( + {}, + fetchIndexResourcesSuccess(indexResourcesAuthenticated) + ); + expect(newState.links).toBe(indexResourcesAuthenticated._links); }); }); - it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { - fetchMock.getOnce(index_url, { - status: 500 + describe("index resources selectors", () => { + const error = new Error("something goes wrong"); + + it("should return true, when fetch index resources is pending", () => { + const state = { + pending: { + [FETCH_INDEXRESOURCES]: true + } + }; + expect(isFetchIndexResourcesPending(state)).toEqual(true); }); - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - const actions = store.getActions(); - expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); - expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); - expect(actions[1].payload).toBeDefined(); + it("should return false, when fetch index resources is not pending", () => { + expect(isFetchIndexResourcesPending({})).toEqual(false); + }); + + it("should return error when fetch index resources did fail", () => { + const state = { + failure: { + [FETCH_INDEXRESOURCES]: error + } + }; + expect(getFetchIndexResourcesFailure(state)).toEqual(error); + }); + + it("should return undefined when fetch index resources did not fail", () => { + expect(getFetchIndexResourcesFailure({})).toBe(undefined); + }); + + it("should return all links", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); + }); + + // ui plugins link + it("should return ui plugins link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + it("should return ui plugins links when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + // me link + it("should return me link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); + }); + + it("should return undefined for me link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getMeLink(state)).toBe(undefined); + }); + + // logout link + it("should return logout link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLogoutLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLogoutLink(state)).toBe(undefined); + }); + + // login link + it("should return login link when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLoginLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for login link when authenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLoginLink(state)).toBe(undefined); + }); + + // users link + it("should return users link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUsersLink(state)).toBe( + "http://localhost:8081/scm/api/v2/users/" + ); + }); + + it("should return undefined for users link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUsersLink(state)).toBe(undefined); + }); + + // groups link + it("should return groups link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/groups/" + ); + }); + + it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGroupsLink(state)).toBe(undefined); + }); + + // config link + it("should return config link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config" + ); + }); + + it("should return undefined for config link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getConfigLink(state)).toBe(undefined); + }); + + // repositories link + it("should return repositories link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe( + "http://localhost:8081/scm/api/v2/repositories/" + ); + }); + + it("should return config for repositories link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe(undefined); + }); + + // hgConfig link + it("should return hgConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/hg" + ); + }); + + it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe(undefined); + }); + + // gitConfig link + it("should return gitConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/git" + ); + }); + + it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe(undefined); + }); + + // svnConfig link + it("should return svnConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/svn" + ); + }); + + it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe(undefined); + }); + + // Autocomplete links + it("should return link collection", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinkCollection(state, "autocomplete")).toEqual( + indexResourcesAuthenticated._links.autocomplete + ); + }); + + it("should return user autocomplete link", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUserAutoCompleteLink(state)).toEqual( + "http://localhost:8081/scm/api/v2/autocomplete/users" + ); + }); + + it("should return group autocomplete link", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupAutoCompleteLink(state)).toEqual( + "http://localhost:8081/scm/api/v2/autocomplete/groups" + ); }); }); }); - -describe("index resources reducer", () => { - it("should return empty object, if state and action is undefined", () => { - expect(reducer()).toEqual({}); - }); - - it("should return the same state, if the action is undefined", () => { - const state = { x: true }; - expect(reducer(state)).toBe(state); - }); - - it("should return the same state, if the action is unknown to the reducer", () => { - const state = { x: true }; - expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); - }); - - it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { - const newState = reducer( - {}, - fetchIndexResourcesSuccess(indexResourcesAuthenticated) - ); - expect(newState.links).toBe(indexResourcesAuthenticated._links); - }); -}); - -describe("index resources selectors", () => { - const error = new Error("something goes wrong"); - - it("should return true, when fetch index resources is pending", () => { - const state = { - pending: { - [FETCH_INDEXRESOURCES]: true - } - }; - expect(isFetchIndexResourcesPending(state)).toEqual(true); - }); - - it("should return false, when fetch index resources is not pending", () => { - expect(isFetchIndexResourcesPending({})).toEqual(false); - }); - - it("should return error when fetch index resources did fail", () => { - const state = { - failure: { - [FETCH_INDEXRESOURCES]: error - } - }; - expect(getFetchIndexResourcesFailure(state)).toEqual(error); - }); - - it("should return undefined when fetch index resources did not fail", () => { - expect(getFetchIndexResourcesFailure({})).toBe(undefined); - }); - - it("should return all links", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); - }); - - // ui plugins link - it("should return ui plugins link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getUiPluginsLink(state)).toBe( - "http://localhost:8081/scm/api/v2/ui/plugins" - ); - }); - - it("should return ui plugins links when unauthenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getUiPluginsLink(state)).toBe( - "http://localhost:8081/scm/api/v2/ui/plugins" - ); - }); - - // me link - it("should return me link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); - }); - - it("should return undefined for me link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getMeLink(state)).toBe(undefined); - }); - - // logout link - it("should return logout link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLogoutLink(state)).toBe( - "http://localhost:8081/scm/api/v2/auth/access_token" - ); - }); - - it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getLogoutLink(state)).toBe(undefined); - }); - - // login link - it("should return login link when unauthenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getLoginLink(state)).toBe( - "http://localhost:8081/scm/api/v2/auth/access_token" - ); - }); - - it("should return undefined for login link when authenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLoginLink(state)).toBe(undefined); - }); - - // users link - it("should return users link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getUsersLink(state)).toBe("http://localhost:8081/scm/api/v2/users/"); - }); - - it("should return undefined for users link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getUsersLink(state)).toBe(undefined); - }); - - // groups link - it("should return groups link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getGroupsLink(state)).toBe("http://localhost:8081/scm/api/v2/groups/"); - }); - - it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getGroupsLink(state)).toBe(undefined); - }); - - // config link - it("should return config link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config" - ); - }); - - it("should return undefined for config link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getConfigLink(state)).toBe(undefined); - }); - - // repositories link - it("should return repositories link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getRepositoriesLink(state)).toBe( - "http://localhost:8081/scm/api/v2/repositories/" - ); - }); - - it("should return config for repositories link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getRepositoriesLink(state)).toBe(undefined); - }); - - // hgConfig link - it("should return hgConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getHgConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/hg" - ); - }); - - it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getHgConfigLink(state)).toBe(undefined); - }); - - // gitConfig link - it("should return gitConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getGitConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/git" - ); - }); - - it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getGitConfigLink(state)).toBe(undefined); - }); - - // svnConfig link - it("should return svnConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getSvnConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/svn" - ); - }); - - it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getSvnConfigLink(state)).toBe(undefined); - }); -}); diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js index 14f48362b8..483042a779 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetDetails.js +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -3,16 +3,20 @@ import React from "react"; import type { Changeset, Repository } from "@scm-manager/ui-types"; import { Interpolate, translate } from "react-i18next"; import injectSheet from "react-jss"; -import ChangesetTag from "./ChangesetTag"; -import ChangesetAuthor from "./ChangesetAuthor"; -import { parseDescription } from "./changesets"; -import { DateFromNow } from "@scm-manager/ui-components"; -import AvatarWrapper from "./AvatarWrapper"; -import AvatarImage from "./AvatarImage"; + +import { + DateFromNow, + ChangesetId, + ChangesetTag, + ChangesetAuthor, + ChangesetDiff, + AvatarWrapper, + AvatarImage, + changesets, +} from "@scm-manager/ui-components"; + import classNames from "classnames"; -import ChangesetId from "./ChangesetId"; import type { Tag } from "@scm-manager/ui-types"; -import ScmDiff from "../../containers/ScmDiff"; const styles = { spacing: { @@ -31,12 +35,12 @@ class ChangesetDetails extends React.Component { render() { const { changeset, repository, classes } = this.props; - const description = parseDescription(changeset.description); + const description = changesets.parseDescription(changeset.description); const id = ( - + ); - const date = ; + const date = ; return (
@@ -45,12 +49,12 @@ class ChangesetDetails extends React.Component {

- +

- +

{ return ( {item} -
+
); })}

- +
); @@ -91,7 +95,7 @@ class ChangesetDetails extends React.Component { return (
{tags.map((tag: Tag) => { - return ; + return ; })}
); diff --git a/scm-ui/src/repos/containers/BranchSelector.js b/scm-ui/src/repos/containers/BranchSelector.js index 7952c8ad22..3fbd67e451 100644 --- a/scm-ui/src/repos/containers/BranchSelector.js +++ b/scm-ui/src/repos/containers/BranchSelector.js @@ -12,6 +12,9 @@ const styles = { zeroflex: { flexGrow: 0 }, + minWidthOfLabel: { + minWidth: "4.5rem" + }, wrapper: { padding: "1rem 1.5rem 0.25rem 1.5rem", border: "1px solid #eee", diff --git a/scm-ui/src/repos/containers/Changesets.js b/scm-ui/src/repos/containers/Changesets.js index 3d30e9f8be..95bf0459a6 100644 --- a/scm-ui/src/repos/containers/Changesets.js +++ b/scm-ui/src/repos/containers/Changesets.js @@ -12,8 +12,7 @@ import { } from "../modules/changesets"; import {connect} from "react-redux"; -import ChangesetList from "../components/changesets/ChangesetList"; -import {ErrorNotification, getPageFromMatch, LinkPaginator, Loading} from "@scm-manager/ui-components"; +import {ErrorNotification, getPageFromMatch, LinkPaginator, ChangesetList, Loading} from "@scm-manager/ui-components"; import {compose} from "redux"; type Props = { diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index 81de0296fc..a3f69fe70b 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -114,7 +114,7 @@ class RepositoryRoot extends React.Component { return (
-
+
{ - constructor(props: Props) { - super(props); - this.state = { diff: "" }; - } - - componentDidMount() { - const { changeset } = this.props; - const url = changeset._links.diff.href+"?format=GIT"; - apiClient - .get(url) - .then(response => response.text()) - .then(text => this.setState({ ...this.state, diff: text })) - .catch(error => this.setState({ ...this.state, error })); - } - - render() { - const options = { - inputFormat: "diff", - outputFormat: this.props.sideBySide ? "side-by-side" : "line-by-line", - showFiles: false, - matching: "lines" - }; - - const outputHtml = Diff2Html.getPrettyHtml(this.state.diff, options); - - return ( - // eslint-disable-next-line react/no-danger -
- ); - } -} - -export default ScmDiff; diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index 642f6cf395..3e574aa938 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -224,9 +224,8 @@ export function modifyRepo(repository: Repository, callback?: () => void) { .then(() => { dispatch(fetchRepoByLink(repository)); }) - .catch(cause => { - const error = new Error(`failed to modify repo: ${cause.message}`); - dispatch(modifyRepoFailure(repository, error)); + .catch(err => { + dispatch(modifyRepoFailure(repository, err)); }); }; } diff --git a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js index 2828835acf..b5371daa3d 100644 --- a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js +++ b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js @@ -1,23 +1,30 @@ // @flow import React from "react"; -import {translate} from "react-i18next"; -import {Checkbox, InputField, SubmitButton} from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; +import { Autocomplete, SubmitButton } from "@scm-manager/ui-components"; import TypeSelector from "./TypeSelector"; -import type {PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; +import type { + PermissionCollection, + PermissionCreateEntry, + SelectValue +} from "@scm-manager/ui-types"; import * as validator from "./permissionValidation"; type Props = { t: string => string, createPermission: (permission: PermissionCreateEntry) => void, loading: boolean, - currentPermissions: PermissionCollection + currentPermissions: PermissionCollection, + groupAutoCompleteLink: string, + userAutoCompleteLink: string }; type State = { name: string, type: string, groupPermission: boolean, - valid: boolean + valid: boolean, + value?: SelectValue }; class CreatePermissionForm extends React.Component { @@ -28,13 +35,95 @@ class CreatePermissionForm extends React.Component { name: "", type: "READ", groupPermission: false, - valid: true + valid: true, + value: undefined }; } + permissionScopeChanged = event => { + const groupPermission = event.target.value === "GROUP_PERMISSION"; + this.setState({ + groupPermission: groupPermission, + valid: validator.isPermissionValid( + this.state.name, + groupPermission, + this.props.currentPermissions + ) + }); + this.setState({ ...this.state, groupPermission }); + }; + + loadUserAutocompletion = (inputValue: string) => { + return this.loadAutocompletion(this.props.userAutoCompleteLink, inputValue); + }; + + loadGroupAutocompletion = (inputValue: string) => { + return this.loadAutocompletion( + this.props.groupAutoCompleteLink, + inputValue + ); + }; + + loadAutocompletion(url: string, inputValue: string) { + const link = url + "?q="; + return fetch(link + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + const label = element.displayName + ? `${element.displayName} (${element.id})` + : element.id; + return { + value: element, + label + }; + }); + }); + } + renderAutocompletionField = () => { + const { t } = this.props; + if (this.state.groupPermission) { + return ( + + ); + } + return ( + + ); + }; + + groupOrUserSelected = (value: SelectValue) => { + this.setState({ + value, + name: value.value.id, + valid: validator.isPermissionValid( + value.value.id, + this.state.groupPermission, + this.props.currentPermissions + ) + }); + }; + render() { const { t, loading } = this.props; - const { name, type, groupPermission } = this.state; + + const { type } = this.state; return (
@@ -43,23 +132,32 @@ class CreatePermissionForm extends React.Component { {t("permission.add-permission.add-permission-heading")}
+
+ + +
+
- - - + {this.renderAutocompletionField()}
{ type: type }); }; - - handleNameChange = (name: string) => { - this.setState({ - name: name, - valid: validator.isPermissionValid( - name, - this.state.groupPermission, - this.props.currentPermissions - ) - }); - }; - handleGroupPermissionChange = (groupPermission: boolean) => { - this.setState({ - groupPermission: groupPermission, - valid: validator.isPermissionValid( - this.state.name, - groupPermission, - this.props.currentPermissions - ) - }); - }; } export default translate("repos")(CreatePermissionForm); diff --git a/scm-ui/src/repos/permissions/containers/Permissions.js b/scm-ui/src/repos/permissions/containers/Permissions.js index 05c780dc6d..48f4e585f7 100644 --- a/scm-ui/src/repos/permissions/containers/Permissions.js +++ b/scm-ui/src/repos/permissions/containers/Permissions.js @@ -27,6 +27,10 @@ import SinglePermission from "./SinglePermission"; import CreatePermissionForm from "../components/CreatePermissionForm"; import type { History } from "history"; import { getPermissionsLink } from "../../modules/repos"; +import { + getGroupAutoCompleteLink, + getUserAutoCompleteLink +} from "../../../modules/indexResource"; type Props = { namespace: string, @@ -37,6 +41,8 @@ type Props = { hasPermissionToCreate: boolean, loadingCreatePermission: boolean, permissionsLink: string, + groupAutoCompleteLink: string, + userAutoCompleteLink: string, //dispatch functions fetchPermissions: (link: string, namespace: string, repoName: string) => void, @@ -92,7 +98,9 @@ class Permissions extends React.Component { namespace, repoName, loadingCreatePermission, - hasPermissionToCreate + hasPermissionToCreate, + userAutoCompleteLink, + groupAutoCompleteLink } = this.props; if (error) { return ( @@ -113,6 +121,8 @@ class Permissions extends React.Component { createPermission={permission => this.createPermission(permission)} loading={loadingCreatePermission} currentPermissions={permissions} + userAutoCompleteLink={userAutoCompleteLink} + groupAutoCompleteLink={groupAutoCompleteLink} /> ) : null; @@ -165,6 +175,8 @@ const mapStateToProps = (state, ownProps) => { ); const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName); const permissionsLink = getPermissionsLink(state, namespace, repoName); + const groupAutoCompleteLink = getGroupAutoCompleteLink(state); + const userAutoCompleteLink = getUserAutoCompleteLink(state); return { namespace, repoName, @@ -173,7 +185,9 @@ const mapStateToProps = (state, ownProps) => { permissions, hasPermissionToCreate, loadingCreatePermission, - permissionsLink + permissionsLink, + groupAutoCompleteLink, + userAutoCompleteLink }; }; @@ -189,7 +203,9 @@ const mapDispatchToProps = dispatch => { repoName: string, callback?: () => void ) => { - dispatch(createPermission(link, permission, namespace, repoName, callback)); + dispatch( + createPermission(link, permission, namespace, repoName, callback) + ); }, createPermissionReset: (namespace: string, repoName: string) => { dispatch(createPermissionReset(namespace, repoName)); diff --git a/scm-ui/src/repos/permissions/modules/permissions.js b/scm-ui/src/repos/permissions/modules/permissions.js index 154ee8123f..9f5330bbcd 100644 --- a/scm-ui/src/repos/permissions/modules/permissions.js +++ b/scm-ui/src/repos/permissions/modules/permissions.js @@ -1,12 +1,16 @@ // @flow -import type {Action} from "@scm-manager/ui-components"; -import {apiClient} from "@scm-manager/ui-components"; +import type { Action } from "@scm-manager/ui-components"; +import { apiClient } from "@scm-manager/ui-components"; import * as types from "../../../modules/types"; -import type {Permission, PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; -import {isPending} from "../../../modules/pending"; -import {getFailure} from "../../../modules/failure"; -import {Dispatch} from "redux"; +import type { + Permission, + PermissionCollection, + PermissionCreateEntry +} from "@scm-manager/ui-types"; +import { isPending } from "../../../modules/pending"; +import { getFailure } from "../../../modules/failure"; +import { Dispatch } from "redux"; export const FETCH_PERMISSIONS = "scm/permissions/FETCH_PERMISSIONS"; export const FETCH_PERMISSIONS_PENDING = `${FETCH_PERMISSIONS}_${ @@ -141,13 +145,8 @@ export function modifyPermission( callback(); } }) - .catch(cause => { - const error = new Error( - `failed to modify permission: ${cause.message}` - ); - dispatch( - modifyPermissionFailure(permission, error, namespace, repoName) - ); + .catch(err => { + dispatch(modifyPermissionFailure(permission, err, namespace, repoName)); }); }; } @@ -241,15 +240,7 @@ export function createPermission( } }) .catch(err => - dispatch( - createPermissionFailure( - new Error( - `failed to add permission ${permission.name}: ${err.message}` - ), - namespace, - repoName - ) - ) + dispatch(createPermissionFailure(err, namespace, repoName)) ); }; } @@ -318,13 +309,8 @@ export function deletePermission( callback(); } }) - .catch(cause => { - const error = new Error( - `could not delete permission ${permission.name}: ${cause.message}` - ); - dispatch( - deletePermissionFailure(permission, namespace, repoName, error) - ); + .catch(err => { + dispatch(deletePermissionFailure(permission, namespace, repoName, err)); }); }; } diff --git a/scm-ui/src/repos/sources/components/FileTree.js b/scm-ui/src/repos/sources/components/FileTree.js index 1dd11870ae..cbe03df62f 100644 --- a/scm-ui/src/repos/sources/components/FileTree.js +++ b/scm-ui/src/repos/sources/components/FileTree.js @@ -119,7 +119,9 @@ class FileTree extends React.Component { {t("sources.file-tree.lastModified")} - {t("sources.file-tree.description")} + + {t("sources.file-tree.description")} + diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.js index b4e2ad59ea..20905a8354 100644 --- a/scm-ui/src/repos/sources/components/FileTreeLeaf.js +++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.js @@ -6,10 +6,14 @@ import FileSize from "./FileSize"; import FileIcon from "./FileIcon"; import { Link } from "react-router-dom"; import type { File } from "@scm-manager/ui-types"; +import classNames from "classnames"; const styles = { iconColumn: { width: "16px" + }, + wordBreakMinWidth: { + minWidth: "10em" } }; @@ -71,12 +75,14 @@ class FileTreeLeaf extends React.Component { return ( {this.createFileIcon(file)} - {this.createFileName(file)} + {this.createFileName(file)} {fileSize} - {file.description} + + {file.description} + ); } diff --git a/scm-ui/src/repos/sources/containers/Content.js b/scm-ui/src/repos/sources/containers/Content.js index 34d024b2a2..2c5663da0c 100644 --- a/scm-ui/src/repos/sources/containers/Content.js +++ b/scm-ui/src/repos/sources/containers/Content.js @@ -93,7 +93,7 @@ class Content extends React.Component { classes.marginInHeader )} /> - {file.name} + {file.name}
{selector}
@@ -125,11 +125,11 @@ class Content extends React.Component { {t("sources.content.path")} - {file.path} + {file.path} {t("sources.content.branch")} - {revision} + {revision} {t("sources.content.size")} @@ -141,7 +141,7 @@ class Content extends React.Component { {t("sources.content.description")} - {description} + {description} diff --git a/scm-ui/src/repos/sources/containers/HistoryView.js b/scm-ui/src/repos/sources/containers/HistoryView.js index a9e25bacc0..98400248d9 100644 --- a/scm-ui/src/repos/sources/containers/HistoryView.js +++ b/scm-ui/src/repos/sources/containers/HistoryView.js @@ -9,10 +9,10 @@ import type { import { ErrorNotification, Loading, - StatePaginator + StatePaginator, + ChangesetList } from "@scm-manager/ui-components"; import { getHistory } from "./history"; -import ChangesetList from "../../components/changesets/ChangesetList"; type Props = { file: File, diff --git a/scm-ui/src/repos/sources/modules/sources.js b/scm-ui/src/repos/sources/modules/sources.js index 5868c56df3..c6d86d38ee 100644 --- a/scm-ui/src/repos/sources/modules/sources.js +++ b/scm-ui/src/repos/sources/modules/sources.js @@ -25,8 +25,7 @@ export function fetchSources( dispatch(fetchSourcesSuccess(repository, revision, path, sources)); }) .catch(err => { - const error = new Error(`failed to fetch sources: ${err.message}`); - dispatch(fetchSourcesFailure(repository, revision, path, error)); + dispatch(fetchSourcesFailure(repository, revision, path, err)); }); }; } diff --git a/scm-ui/src/users/components/SetUserPassword.js b/scm-ui/src/users/components/SetUserPassword.js index 6c2c1ca25d..d318025f21 100644 --- a/scm-ui/src/users/components/SetUserPassword.js +++ b/scm-ui/src/users/components/SetUserPassword.js @@ -19,7 +19,8 @@ type State = { password: string, loading: boolean, error?: Error, - passwordChanged: boolean + passwordChanged: boolean, + passwordValid: boolean }; class SetUserPassword extends React.Component { @@ -32,7 +33,8 @@ class SetUserPassword extends React.Component { passwordConfirmationError: false, validatePasswordError: false, validatePassword: "", - passwordChanged: false + passwordChanged: false, + passwordValid: false }; } @@ -104,7 +106,7 @@ class SetUserPassword extends React.Component { key={this.state.passwordChanged ? "changed" : "unchanged"} /> @@ -112,8 +114,8 @@ class SetUserPassword extends React.Component { ); } - passwordChanged = (password: string) => { - this.setState({ ...this.state, password }); + passwordChanged = (password: string, passwordValid: boolean) => { + this.setState({ ...this.state, password, passwordValid: (!!password && passwordValid) }); }; onClose = () => { diff --git a/scm-ui/src/users/components/UserForm.js b/scm-ui/src/users/components/UserForm.js index 8e417d7ae4..82fe79c2ab 100644 --- a/scm-ui/src/users/components/UserForm.js +++ b/scm-ui/src/users/components/UserForm.js @@ -22,7 +22,8 @@ type State = { user: User, mailValidationError: boolean, nameValidationError: boolean, - displayNameValidationError: boolean + displayNameValidationError: boolean, + passwordValid: boolean }; class UserForm extends React.Component { @@ -41,7 +42,8 @@ class UserForm extends React.Component { }, mailValidationError: false, displayNameValidationError: false, - nameValidationError: false + nameValidationError: false, + passwordValid: false }; } @@ -61,7 +63,6 @@ class UserForm extends React.Component { isValid = () => { const user = this.state.user; - const passwordValid = this.props.user ? !this.isFalsy(user.password) : true; return !( this.state.nameValidationError || this.state.mailValidationError || @@ -69,7 +70,7 @@ class UserForm extends React.Component { this.isFalsy(user.name) || this.isFalsy(user.displayName) || this.isFalsy(user.mail) || - passwordValid + !this.state.passwordValid ); }; @@ -180,9 +181,10 @@ class UserForm extends React.Component { }); }; - handlePasswordChange = (password: string) => { + handlePasswordChange = (password: string, passwordValid: boolean) => { this.setState({ - user: { ...this.state.user, password } + user: { ...this.state.user, password }, + passwordValid: !this.isFalsy(password) && passwordValid }); }; diff --git a/scm-ui/src/users/modules/users.js b/scm-ui/src/users/modules/users.js index fe751d13d4..ab330d9ffd 100644 --- a/scm-ui/src/users/modules/users.js +++ b/scm-ui/src/users/modules/users.js @@ -35,8 +35,6 @@ export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`; const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2"; -// TODO i18n for error messages - // fetch users export function fetchUsers(link: string) { @@ -57,9 +55,8 @@ export function fetchUsersByLink(link: string) { .then(data => { dispatch(fetchUsersSuccess(data)); }) - .catch(cause => { - const error = new Error(`could not fetch users: ${cause.message}`); - dispatch(fetchUsersFailure(link, error)); + .catch(err => { + dispatch(fetchUsersFailure(link, err)); }); }; } @@ -108,9 +105,8 @@ function fetchUser(link: string, name: string) { .then(data => { dispatch(fetchUserSuccess(data)); }) - .catch(cause => { - const error = new Error(`could not fetch user: ${cause.message}`); - dispatch(fetchUserFailure(name, error)); + .catch(err => { + dispatch(fetchUserFailure(name, err)); }); }; } @@ -155,13 +151,7 @@ export function createUser(link: string, user: User, callback?: () => void) { callback(); } }) - .catch(err => - dispatch( - createUserFailure( - new Error(`failed to add user ${user.name}: ${err.message}`) - ) - ) - ); + .catch(err => dispatch(createUserFailure(err))); }; } @@ -260,11 +250,8 @@ export function deleteUser(user: User, callback?: () => void) { callback(); } }) - .catch(cause => { - const error = new Error( - `could not delete user ${user.name}: ${cause.message}` - ); - dispatch(deleteUserFailure(user, error)); + .catch(err => { + dispatch(deleteUserFailure(user, err)); }); }; } diff --git a/scm-ui/styles/scm.scss b/scm-ui/styles/scm.scss index 15edbe76be..7a0eb89aa1 100644 --- a/scm-ui/styles/scm.scss +++ b/scm-ui/styles/scm.scss @@ -30,6 +30,14 @@ $mint: #11dfd0; padding: 0 0 0 3.8em !important; } +.is-word-break { + -webkit-hyphens: auto; + -moz-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto; + word-break: break-all; +} + .main { min-height: calc(100vh - 260px); } diff --git a/scm-ui/yarn.lock b/scm-ui/yarn.lock index 2167ed28c9..3ddf27be96 100644 --- a/scm-ui/yarn.lock +++ b/scm-ui/yarn.lock @@ -583,6 +583,12 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" +"@babel/runtime@^7.1.2": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39" + dependencies: + regenerator-runtime "^0.12.0" + "@babel/template@^7.1.0", "@babel/template@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" @@ -613,6 +619,46 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + "@fortawesome/fontawesome-free@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.3.1.tgz#5466b8f31c1f493a96754c1426c25796d0633dd9" @@ -652,9 +698,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -1144,6 +1190,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1157,6 +1220,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-macros@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28" + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "http://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -1667,12 +1741,24 @@ cached-path-relative@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + dependencies: + callsites "^2.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" dependencies: callsites "^0.2.0" +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -1839,7 +1925,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5, classnames@^2.2.6: +classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -2049,7 +2135,7 @@ contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" -convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" dependencies: @@ -2086,6 +2172,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04" + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + coveralls@^2.11.3: version "2.13.3" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" @@ -2103,6 +2198,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2213,6 +2320,10 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -2438,14 +2549,14 @@ dev-ip@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" -diff2html@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.4.0.tgz#de632384eefa5a7f6b0e92eafb1fa25d22dc88ab" +diff2html@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.5.0.tgz#2d16f1a8f115354733b16b0264a594fa7db98aa2" dependencies: diff "^3.5.0" hogan.js "^3.0.2" - lodash "^4.17.10" - whatwg-fetch "^2.0.4" + lodash "^4.17.11" + whatwg-fetch "^3.0.0" diff@^3.2.0, diff@^3.5.0: version "3.5.0" @@ -2476,6 +2587,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-helpers@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" @@ -2584,6 +2701,13 @@ emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -3205,6 +3329,10 @@ find-node-modules@^1.0.4: findup-sync "0.4.2" merge "^1.2.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3476,10 +3604,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -gitdiff-parser@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/gitdiff-parser/-/gitdiff-parser-0.1.2.tgz#26a256e05e9c2d5016b512a96c1dacb40862b92a" - glob-base@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -4096,6 +4220,13 @@ immutable@^3: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" @@ -4297,6 +4428,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" @@ -4956,7 +5091,7 @@ js-yaml@3.6.1: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.12.0, js-yaml@^3.7.0: +js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -5342,10 +5477,6 @@ lodash.escape@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" -lodash.findlastindex@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.findlastindex/-/lodash.findlastindex-4.6.0.tgz#b8375ac0f02e9b926375cdf8dc3ea814abf9c6ac" - lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -5378,10 +5509,6 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" -lodash.mapvalues@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" - lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" @@ -5419,7 +5546,7 @@ lodash.templatesettings@^3.0.0: lodash._reinterpolate "^3.0.0" lodash.escape "^3.0.0" -lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.10: +lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -5431,7 +5558,7 @@ log-driver@1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" dependencies: @@ -5542,6 +5669,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" @@ -5718,7 +5849,7 @@ mixin-deep@^1.2.0: mkdirp@0.3.0: version "0.3.0" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: version "0.5.1" @@ -5959,7 +6090,7 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" -nopt@1.0.10: +nopt@1.0.10, nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" dependencies: @@ -6776,18 +6907,6 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-diff-view@^1.7.0: - version "1.8.1" - resolved "https://registry.yarnpkg.com/react-diff-view/-/react-diff-view-1.8.1.tgz#0b9b4adcb92de6730d28177d68654dfcc2097f73" - dependencies: - classnames "^2.2.6" - gitdiff-parser "^0.1.2" - leven "^2.1.0" - lodash.escape "^4.0.1" - lodash.findlastindex "^4.6.0" - lodash.mapvalues "^4.6.0" - warning "^4.0.1" - react-dom@^16.4.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" @@ -6805,6 +6924,12 @@ react-i18next@^7.9.0: html-parse-stringify2 "2.0.1" prop-types "^15.6.0" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-is@^16.5.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" @@ -6819,6 +6944,10 @@ react-jss@^8.6.0: prop-types "^15.6.0" theming "^1.3.0" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-redux@^5.0.7: version "5.0.7" resolved "http://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8" @@ -6868,6 +6997,18 @@ react-router@^4.2.0, react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-syntax-highlighter@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-9.0.1.tgz#cad91692e1976f68290f24762ac3451b1fec3d26" @@ -6887,6 +7028,15 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.4.1: react-is "^16.5.2" schedule "^0.5.0" +react-transition-group@^2.2.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874" + dependencies: + dom-helpers "^3.3.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.4.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" @@ -7068,6 +7218,10 @@ regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -7286,7 +7440,7 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: @@ -7683,6 +7837,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + space-separated-tokens@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.2.tgz#e95ab9d19ae841e200808cd96bc7bd0adbbb3412" @@ -7933,6 +8091,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -8135,6 +8301,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" @@ -8534,10 +8706,6 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.24" -whatwg-fetch@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" - whatwg-fetch@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java index 9555ad66b5..d7846dbac5 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java @@ -79,14 +79,14 @@ import sonia.scm.repository.spi.HookEventFacade; import sonia.scm.repository.xml.XmlRepositoryDAO; import sonia.scm.schedule.QuartzScheduler; import sonia.scm.schedule.Scheduler; +import sonia.scm.security.AccessTokenCookieIssuer; import sonia.scm.security.AuthorizationChangedEventProducer; import sonia.scm.security.CipherHandler; import sonia.scm.security.CipherUtil; import sonia.scm.security.ConfigurableLoginAttemptHandler; -import sonia.scm.security.DefaultJwtAccessTokenRefreshStrategy; +import sonia.scm.security.DefaultAccessTokenCookieIssuer; import sonia.scm.security.DefaultKeyGenerator; import sonia.scm.security.DefaultSecuritySystem; -import sonia.scm.security.JwtAccessTokenRefreshStrategy; import sonia.scm.security.KeyGenerator; import sonia.scm.security.LoginAttemptHandler; import sonia.scm.security.SecuritySystem; @@ -320,6 +320,7 @@ public class ScmServletModule extends ServletModule // bind events // bind(LastModifiedUpdateListener.class); + bind(AccessTokenCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class); bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java new file mode 100644 index 0000000000..33e1e368d7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java @@ -0,0 +1,31 @@ +package sonia.scm.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.NotSupportedException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class NotSupportedExceptionMapper implements ExceptionMapper { + + private static final Logger LOG = LoggerFactory.getLogger(NotSupportedExceptionMapper.class); + + @Override + public Response toResponse(NotSupportedException exception) { + LOG.debug("illegal media type"); + ErrorDto error = new ErrorDto(); + error.setTransactionId(MDC.get("transaction_id")); + error.setMessage("illegal media type"); + error.setErrorCode("8pRBYDURx1"); + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity(error) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/BadRequestExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/BadRequestExceptionMapper.java new file mode 100644 index 0000000000..e529bc7c1a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/BadRequestExceptionMapper.java @@ -0,0 +1,16 @@ +package sonia.scm.api.rest; + +import sonia.scm.BadRequestException; +import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; + +import javax.inject.Inject; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +@Provider +public class BadRequestExceptionMapper extends ContextualExceptionMapper { + @Inject + public BadRequestExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { + super(BadRequestException.class, Response.Status.BAD_REQUEST, mapper); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java index 6bf6c8e803..64b20fc10c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java @@ -46,7 +46,7 @@ import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; -import sonia.scm.NotSupportedFeatureException; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.Type; import sonia.scm.api.rest.RestActionUploadResult; import sonia.scm.api.v2.resources.RepositoryResource; @@ -394,7 +394,7 @@ public class RepositoryImportResource response = Response.ok(result).build(); } - catch (NotSupportedFeatureException ex) + catch (FeatureNotSupportedException ex) { logger .warn( @@ -609,7 +609,7 @@ public class RepositoryImportResource types.add(t); } } - catch (NotSupportedFeatureException ex) + catch (FeatureNotSupportedException ex) { if (logger.isTraceEnabled()) { @@ -711,7 +711,7 @@ public class RepositoryImportResource } } } - catch (NotSupportedFeatureException ex) + catch (FeatureNotSupportedException ex) { throw new WebApplicationException(ex, Response.Status.BAD_REQUEST); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/NotSupportedFeatureExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/NotSupportedFeatureExceptionMapper.java deleted file mode 100644 index 6a48663aa5..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/NotSupportedFeatureExceptionMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package sonia.scm.api.v2; - -import sonia.scm.NotSupportedFeatureException; -import sonia.scm.api.rest.ContextualExceptionMapper; -import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; - -import javax.inject.Inject; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class NotSupportedFeatureExceptionMapper extends ContextualExceptionMapper { - @Inject - public NotSupportedFeatureExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - super(NotSupportedFeatureException.class, Response.Status.BAD_REQUEST, mapper); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java index a0d73ad24e..658abbded8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -128,49 +128,6 @@ public class BranchRootResource { } } - @Path("{branch}/diffchangesets/{otherBranchName}") - @GET - @StatusCodes({ - @ResponseCode(code = 200, condition = "success"), - @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), - @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), - @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @Produces(VndMediaType.CHANGESET_COLLECTION) - @TypeHint(CollectionDto.class) - public Response changesetDiff(@PathParam("namespace") String namespace, - @PathParam("name") String name, - @PathParam("branch") String branchName, - @PathParam("otherBranchName") String otherBranchName, - @DefaultValue("0") @QueryParam("page") int page, - @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws Exception { - try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - List allBranches = repositoryService.getBranchesCommand().getBranches().getBranches(); - if (allBranches.stream().noneMatch(branch -> branchName.equals(branch.getName()))) { - throw new NotFoundException("branch", branchName); - } - if (allBranches.stream().noneMatch(branch -> otherBranchName.equals(branch.getName()))) { - throw new NotFoundException("branch", otherBranchName); - } - Repository repository = repositoryService.getRepository(); - RepositoryPermissions.read(repository).check(); - ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService) - .page(page) - .pageSize(pageSize) - .create() - .setBranch(branchName) - .setAncestorChangeset(otherBranchName) - .getChangesets(); - if (changesets != null && changesets.getChangesets() != null) { - PageResult pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal()); - return Response.ok(branchChangesetCollectionToDtoMapper.map(page, pageSize, pageResult, repository, branchName)).build(); - } else { - return Response.ok().build(); - } - } - } - /** * Returns the branches for a repository. * diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java index 8167e28f9a..7ab3ef25a8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java @@ -28,7 +28,6 @@ public abstract class BranchToBranchDtoMapper { Links.Builder linksBuilder = linkingTo() .self(resourceLinks.branch().self(namespaceAndName, target.getName())) .single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, target.getName())).build()) - .single(linkBuilder("changesetDiff", resourceLinks.branch().changesetDiff(namespaceAndName, target.getName())).build()) .single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()) .single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()); target.add(linksBuilder.build()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangePasswordNotAllowedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangePasswordNotAllowedExceptionMapper.java deleted file mode 100644 index 18a6e6e75c..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangePasswordNotAllowedExceptionMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package sonia.scm.api.v2.resources; - -import sonia.scm.api.rest.ContextualExceptionMapper; -import sonia.scm.user.ChangePasswordNotAllowedException; -import sonia.scm.user.InvalidPasswordException; - -import javax.inject.Inject; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class ChangePasswordNotAllowedExceptionMapper extends ContextualExceptionMapper { - @Inject - public ChangePasswordNotAllowedExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - super(ChangePasswordNotAllowedException.class, Response.Status.BAD_REQUEST, mapper); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java index e236b54005..4d15b773cd 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java @@ -25,7 +25,7 @@ public class DiffRootResource { public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; - private static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED"; + static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED"; private final RepositoryServiceFactory serviceFactory; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java new file mode 100644 index 0000000000..a2aaabb1a2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java @@ -0,0 +1,29 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.PageResult; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.Repository; + +import javax.inject.Inject; + +public class IncomingChangesetCollectionToDtoMapper extends ChangesetCollectionToDtoMapper { + + + private final ResourceLinks resourceLinks; + + @Inject + public IncomingChangesetCollectionToDtoMapper(ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper, ResourceLinks resourceLinks) { + super(changesetToChangesetDtoMapper, resourceLinks); + this.resourceLinks = resourceLinks; + } + + public CollectionDto map(int pageNumber, int pageSize, PageResult pageResult, Repository repository, String source, String target) { + return super.map(pageNumber, pageSize, pageResult, repository, () -> createSelfLink(repository, source, target)); + } + + private String createSelfLink(Repository repository, String source, String target) { + return resourceLinks.incoming().changesets(repository.getNamespace(), repository.getName(), source, target); + } + + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java new file mode 100644 index 0000000000..4c43485abd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java @@ -0,0 +1,153 @@ +package sonia.scm.api.v2.resources; + +import com.google.inject.Inject; +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.PageResult; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.DiffFormat; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.util.HttpUtil; +import sonia.scm.web.VndMediaType; + +import javax.validation.constraints.Pattern; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; +import java.io.IOException; + +import static sonia.scm.api.v2.resources.DiffRootResource.DIFF_FORMAT_VALUES_REGEX; +import static sonia.scm.api.v2.resources.DiffRootResource.HEADER_CONTENT_DISPOSITION; + +public class IncomingRootResource { + + + private final RepositoryServiceFactory serviceFactory; + + private final IncomingChangesetCollectionToDtoMapper mapper; + + + @Inject + public IncomingRootResource(RepositoryServiceFactory serviceFactory, IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper) { + this.serviceFactory = serviceFactory; + this.mapper = incomingChangesetCollectionToDtoMapper; + } + + /** + * Get the incoming changesets from source to target + *

+ * Example: + *

+ * - master + * - | + * - _______________ ° m1 + * - e | + * - | ° m2 + * - ° e1 | + * - ______|_______ | + * - | | b + * - f a | + * - | | ° b1 + * - ° f1 ° a1 | + * - ° b2 + * - + *

+ * - /incoming/a/master/changesets -> a1 , e1 + * - /incoming/b/master/changesets -> b1 , b2 + * - /incoming/b/f/changesets -> b1 , b2, m2 + * - /incoming/f/b/changesets -> f1 , e1 + * - /incoming/a/b/changesets -> a1 , e1 + * - /incoming/a/b/changesets -> a1 , e1 + * + * @param namespace + * @param name + * @param source can be a changeset id or a branch name + * @param target can be a changeset id or a branch name + * @param page + * @param pageSize + * @return + * @throws Exception + */ + @Path("{source}/{target}/changesets") + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), + @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.CHANGESET_COLLECTION) + @TypeHint(CollectionDto.class) + public Response incomingChangesets(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("source") String source, + @PathParam("target") String target, + @DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException { + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + Repository repository = repositoryService.getRepository(); + RepositoryPermissions.read(repository).check(); + ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService) + .page(page) + .pageSize(pageSize) + .create() + .setStartChangeset(source) + .setAncestorChangeset(target) + .getChangesets(); + if (changesets != null && changesets.getChangesets() != null) { + PageResult pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal()); + return Response.ok(mapper.map(page, pageSize, pageResult, repository, source, target)).build(); + } else { + return Response.ok().build(); + } + } + } + + + @Path("{source}/{target}/diff") + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), + @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.DIFF) + @TypeHint(CollectionDto.class) + public Response incomingDiff(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("source") String source, + @PathParam("target") String target, + @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format) throws IOException { + + + HttpUtil.checkForCRLFInjection(source); + HttpUtil.checkForCRLFInjection(target); + DiffFormat diffFormat = DiffFormat.valueOf(format); + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + StreamingOutput responseEntry = output -> + repositoryService.getDiffCommand() + .setRevision(source) + .setAncestorChangeset(target) + .setFormat(diffFormat) + .retrieveContent(output); + + return Response.ok(responseEntry) + .header(HEADER_CONTENT_DISPOSITION, HttpUtil.createContentDispositionAttachmentHeader(String.format("%s-%s.diff", name, source))) + .build(); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InvalidPasswordExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InvalidPasswordExceptionMapper.java deleted file mode 100644 index 7a1d311a1c..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InvalidPasswordExceptionMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package sonia.scm.api.v2.resources; - -import sonia.scm.api.rest.ContextualExceptionMapper; -import sonia.scm.user.InvalidPasswordException; - -import javax.inject.Inject; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class InvalidPasswordExceptionMapper extends ContextualExceptionMapper { - - @Inject - public InvalidPasswordExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - super(InvalidPasswordException.class, Response.Status.BAD_REQUEST, mapper); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index e6cf6721a5..66eadaad7d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -43,6 +43,8 @@ public class MapperModule extends AbstractModule { bind(ScmViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ScmViolationExceptionToErrorDtoMapper.class).getClass()); bind(ExceptionWithContextToErrorDtoMapper.class).to(Mappers.getMapper(ExceptionWithContextToErrorDtoMapper.class).getClass()); + bind(MergeResultToDtoMapper.class).to(Mappers.getMapper(MergeResultToDtoMapper.class).getClass()); + // no mapstruct required bind(UIPluginDtoMapper.class); bind(UIPluginDtoCollectionMapper.class); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java new file mode 100644 index 0000000000..0661d6a4ef --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java @@ -0,0 +1,14 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.NotEmpty; + +@Getter @Setter +public class MergeCommandDto { + + @NotEmpty + private String sourceRevision; + @NotEmpty + private String targetRevision; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java new file mode 100644 index 0000000000..63fa2274ec --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java @@ -0,0 +1,87 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpStatus; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.MergeCommandBuilder; +import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +@Slf4j +public class MergeResource { + + private final RepositoryServiceFactory serviceFactory; + private final MergeResultToDtoMapper mapper; + + @Inject + public MergeResource(RepositoryServiceFactory serviceFactory, MergeResultToDtoMapper mapper) { + this.serviceFactory = serviceFactory; + this.mapper = mapper; + } + + @POST + @Path("") + @Produces(VndMediaType.MERGE_RESULT) + @Consumes(VndMediaType.MERGE_COMMAND) + @StatusCodes({ + @ResponseCode(code = 204, condition = "merge has been executed successfully"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the privilege to write the repository"), + @ResponseCode(code = 409, condition = "The branches could not be merged automatically due to conflicts (conflicting files will be returned)"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response merge(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + log.info("Merge in Repository {}/{} from {} to {}", namespace, name, mergeCommand.getSourceRevision(), mergeCommand.getTargetRevision()); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + MergeCommandResult mergeCommandResult = createMergeCommand(mergeCommand, repositoryService).executeMerge(); + if (mergeCommandResult.isSuccess()) { + return Response.noContent().build(); + } else { + return Response.status(HttpStatus.SC_CONFLICT).entity(mapper.map(mergeCommandResult)).build(); + } + } + } + + @POST + @Path("dry-run/") + @StatusCodes({ + @ResponseCode(code = 204, condition = "merge can be done automatically"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 409, condition = "The branches can not be merged automatically due to conflicts"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response dryRun(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + log.info("Merge in Repository {}/{} from {} to {}", namespace, name, mergeCommand.getSourceRevision(), mergeCommand.getTargetRevision()); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + MergeDryRunCommandResult mergeCommandResult = createMergeCommand(mergeCommand, repositoryService).dryRun(); + if (mergeCommandResult.isMergeable()) { + return Response.noContent().build(); + } else { + return Response.status(HttpStatus.SC_CONFLICT).build(); + } + } + } + + private MergeCommandBuilder createMergeCommand(MergeCommandDto mergeCommand, RepositoryService repositoryService) { + return repositoryService + .getMergeCommand() + .setBranchToMerge(mergeCommand.getSourceRevision()) + .setTargetBranch(mergeCommand.getTargetRevision()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java new file mode 100644 index 0000000000..fa523153cf --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java @@ -0,0 +1,12 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Collection; + +@Getter +@Setter +public class MergeResultDto { + private Collection filesWithConflict; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java new file mode 100644 index 0000000000..1dbbe8aacd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java @@ -0,0 +1,9 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.Mapper; +import sonia.scm.repository.api.MergeCommandResult; + +@Mapper +public interface MergeResultToDtoMapper { + MergeResultDto map(MergeCommandResult result); +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index f65235db0b..b884a37771 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -10,7 +10,6 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.inject.Provider; import javax.validation.Valid; import javax.ws.rs.Consumes; @@ -44,6 +43,8 @@ public class RepositoryResource { private final Provider diffRootResource; private final Provider modificationsRootResource; private final Provider fileHistoryRootResource; + private final Provider mergeResource; + private final Provider incomingRootResource; @Inject public RepositoryResource( @@ -56,8 +57,9 @@ public class RepositoryResource { Provider permissionRootResource, Provider diffRootResource, Provider modificationsRootResource, - Provider fileHistoryRootResource - ) { + Provider fileHistoryRootResource, + Provider incomingRootResource, + Provider mergeResource) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; @@ -71,6 +73,8 @@ public class RepositoryResource { this.diffRootResource = diffRootResource; this.modificationsRootResource = modificationsRootResource; this.fileHistoryRootResource = fileHistoryRootResource; + this.mergeResource = mergeResource; + this.incomingRootResource = incomingRootResource; } /** @@ -194,8 +198,18 @@ public class RepositoryResource { return permissionRootResource.get(); } - @Path("modifications/") - public ModificationsRootResource modifications() {return modificationsRootResource.get(); } + @Path("modifications/") + public ModificationsRootResource modifications() { + return modificationsRootResource.get(); + } + + @Path("incoming/") + public IncomingRootResource incoming() { + return incomingRootResource.get(); + } + + @Path("merge/") + public MergeResource merge() {return mergeResource.get(); } private Optional handleNotArchived(Throwable throwable) { if (throwable instanceof RepositoryIsNotArchivedException) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 29a4107aad..30ccb79735 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -6,6 +6,7 @@ import de.otto.edison.hal.Links; import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; +import sonia.scm.repository.Feature; import sonia.scm.repository.HealthCheckFailure; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -55,6 +56,14 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper getParentKey() { return ofNullable(claims.get(PARENT_TOKEN_ID_CLAIM_KEY).toString()); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java b/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java index a369db66bd..f1f29bfe51 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java +++ b/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java @@ -51,6 +51,7 @@ import static com.google.common.base.Preconditions.*; //~--- JDK imports ------------------------------------------------------------ import java.security.SecureRandom; +import java.util.Random; import javax.inject.Inject; import javax.inject.Singleton; @@ -88,12 +89,17 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter */ @Inject @SuppressWarnings("unchecked") - public SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory) + public SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory) { + this(storeFactory, new SecureRandom()); + } + + SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory, Random random) { store = storeFactory .withType(SecureKey.class) .withName(STORE_NAME) .build(); + this.random = random; } //~--- methods -------------------------------------------------------------- @@ -112,7 +118,9 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter SecureKey key = store.get(subject); - checkState(key != null, "could not resolve key for subject %s", subject); + if (key == null) { + return getSecureKey(subject).getBytes(); + } return key.getBytes(); } @@ -161,7 +169,7 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter //~--- fields --------------------------------------------------------------- /** secure randon */ - private final SecureRandom random = new SecureRandom(); + private final Random random; /** configuration entry store */ private final ConfigurationEntryStore store; diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java index f85c0fbbbd..6747d40228 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java @@ -1,5 +1,6 @@ package sonia.scm.web.security; +import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,7 +65,13 @@ public class TokenRefreshFilter extends HttpFilter { } private void examineToken(HttpServletRequest request, HttpServletResponse response, BearerToken token) { - AccessToken accessToken = resolver.resolve(token); + AccessToken accessToken; + try { + accessToken = resolver.resolve(token); + } catch (AuthenticationException e) { + LOG.trace("could not resolve token", e); + return; + } if (accessToken instanceof JwtAccessToken) { refresher.refresh((JwtAccessToken) accessToken) .ifPresent(jwtAccessToken -> refreshToken(request, response, jwtAccessToken)); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java index 42428f9f77..1123dc94ce 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java @@ -18,6 +18,7 @@ import sonia.scm.security.AccessToken; import sonia.scm.security.AccessTokenBuilder; import sonia.scm.security.AccessTokenBuilderFactory; import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.DefaultAccessTokenCookieIssuer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -46,7 +47,7 @@ public class AuthenticationResourceTest { @Mock private AccessTokenBuilder accessTokenBuilder; - private AccessTokenCookieIssuer cookieIssuer = new AccessTokenCookieIssuer(mock(ScmConfiguration.class)); + private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); private static final String AUTH_JSON_TRILLIAN = "{\n" + "\t\"cookie\": true,\n" + diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java index 9638a8aa49..fe205e88a1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java @@ -4,9 +4,9 @@ import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.mock.MockDispatcherFactory; import sonia.scm.api.rest.AlreadyExistsExceptionMapper; import sonia.scm.api.rest.AuthorizationExceptionMapper; +import sonia.scm.api.rest.BadRequestExceptionMapper; import sonia.scm.api.rest.ConcurrentModificationExceptionMapper; import sonia.scm.api.v2.NotFoundExceptionMapper; -import sonia.scm.api.v2.NotSupportedFeatureExceptionMapper; public class DispatcherMock { public static Dispatcher createDispatcher(Object resource) { @@ -18,9 +18,7 @@ public class DispatcherMock { dispatcher.getProviderFactory().register(new ConcurrentModificationExceptionMapper(mapper)); dispatcher.getProviderFactory().registerProvider(AuthorizationExceptionMapper.class); dispatcher.getProviderFactory().register(new InternalRepositoryExceptionMapper(mapper)); - dispatcher.getProviderFactory().register(new ChangePasswordNotAllowedExceptionMapper(mapper)); - dispatcher.getProviderFactory().register(new InvalidPasswordExceptionMapper(mapper)); - dispatcher.getProviderFactory().register(new NotSupportedFeatureExceptionMapper(mapper)); + dispatcher.getProviderFactory().register(new BadRequestExceptionMapper(mapper)); return dispatcher; } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java new file mode 100644 index 0000000000..e4495a0455 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java @@ -0,0 +1,255 @@ +package sonia.scm.api.v2.resources; + + +import com.google.inject.util.Providers; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.subject.support.SubjectThreadState; +import org.apache.shiro.util.ThreadContext; +import org.apache.shiro.util.ThreadState; +import org.assertj.core.util.Lists; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.NotFoundException; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Person; +import sonia.scm.repository.Repository; +import sonia.scm.repository.api.DiffCommandBuilder; +import sonia.scm.repository.api.LogCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.web.VndMediaType; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.Silent.class) +@Slf4j +public class IncomingRootResourceTest extends RepositoryTestBase { + + + public static final String INCOMING_PATH = "space/repo/incoming/"; + public static final String INCOMING_CHANGESETS_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH; + public static final String INCOMING_DIFF_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH; + + private Dispatcher dispatcher; + + private final URI baseUri = URI.create("/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @Mock + private RepositoryServiceFactory serviceFactory; + + @Mock + private RepositoryService repositoryService; + + @Mock + private LogCommandBuilder logCommandBuilder; + + @Mock + private DiffCommandBuilder diffCommandBuilder; + + + private IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper; + + @InjectMocks + private ChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; + + private IncomingRootResource incomingRootResource; + + + private final Subject subject = mock(Subject.class); + private final ThreadState subjectThreadState = new SubjectThreadState(subject); + + + @Before + public void prepareEnvironment() { + incomingChangesetCollectionToDtoMapper = new IncomingChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks); + incomingRootResource = new IncomingRootResource(serviceFactory, incomingChangesetCollectionToDtoMapper); + super.incomingRootResource = Providers.of(incomingRootResource); + dispatcher = DispatcherMock.createDispatcher(getRepositoryRootResource()); + when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); + when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService); + when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo")); + when(repositoryService.getLogCommand()).thenReturn(logCommandBuilder); + when(repositoryService.getDiffCommand()).thenReturn(diffCommandBuilder); + dispatcher.getProviderFactory().registerProvider(CRLFInjectionExceptionMapper.class); + subjectThreadState.bind(); + ThreadContext.bind(subject); + when(subject.isPermitted(any(String.class))).thenReturn(true); + } + + @After + public void cleanupContext() { + ThreadContext.unbindSubject(); + } + + @Test + public void shouldGetIncomingChangesets() throws Exception { + String id = "revision_123"; + Instant creationDate = Instant.now(); + String authorName = "name"; + String authorEmail = "em@i.l"; + String commit = "my branch commit"; + ChangesetPagingResult changesetPagingResult = mock(ChangesetPagingResult.class); + List changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + when(changesetPagingResult.getChangesets()).thenReturn(changesetList); + when(changesetPagingResult.getTotal()).thenReturn(1); + when(logCommandBuilder.setPagingStart(0)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setPagingLimit(10)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setStartChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.setAncestorChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_CHANGESETS_URL + "src_changeset_id/target_changeset_id/changesets") + .accept(VndMediaType.CHANGESET_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(200, response.getStatus()); + 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))); + } + + @Test + public void shouldGetSinglePageOfIncomingChangesets() throws Exception { + String id = "revision_123"; + Instant creationDate = Instant.now(); + String authorName = "name"; + String authorEmail = "em@i.l"; + String commit = "my branch commit"; + ChangesetPagingResult changesetPagingResult = mock(ChangesetPagingResult.class); + List changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + when(changesetPagingResult.getChangesets()).thenReturn(changesetList); + when(changesetPagingResult.getTotal()).thenReturn(1); + when(logCommandBuilder.setPagingStart(20)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setPagingLimit(10)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setStartChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.setAncestorChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_CHANGESETS_URL + "src_changeset_id/target_changeset_id/changesets?page=2") + .accept(VndMediaType.CHANGESET_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(200, response.getStatus()); + 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))); + } + + @Test + public void shouldGetDiffs() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenReturn(diffCommandBuilder); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()) + .isEqualTo(200); + String expectedHeader = "Content-Disposition"; + String expectedValue = "attachment; filename=\"repo-src_changeset_id.diff\"; filename*=utf-8''repo-src_changeset_id.diff"; + assertThat(response.getOutputHeaders().containsKey(expectedHeader)).isTrue(); + assertThat((String) response.getOutputHeaders().get("Content-Disposition").get(0)) + .contains(expectedValue); + } + + @Test + public void shouldGet404OnMissingRepository() throws URISyntaxException { + when(serviceFactory.create(any(NamespaceAndName.class))).thenThrow(new NotFoundException("Text", "x")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(404, response.getStatus()); + } + + @Test + public void shouldGet404OnMissingRevision() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(404, response.getStatus()); + } + + @Test + public void shouldGet400OnCrlfInjection() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(400, response.getStatus()); + assertThat(response.getContentAsString()).contains("parameter contains an illegal character"); + } + + @Test + public void shouldGet400OnUnknownFormat() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Test", "test")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff?format=Unknown") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(400, response.getStatus()); + } + + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java new file mode 100644 index 0000000000..d47ff35e5f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java @@ -0,0 +1,150 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.io.Resources; +import com.google.inject.util.Providers; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.MergeCommandBuilder; +import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.spi.MergeCommand; +import sonia.scm.web.VndMediaType; + +import java.net.URL; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MergeResourceTest extends RepositoryTestBase { + + public static final String MERGE_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/merge/"; + + private Dispatcher dispatcher; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService repositoryService; + @Mock + private MergeCommand mergeCommand; + @InjectMocks + private MergeCommandBuilder mergeCommandBuilder; + private MergeResultToDtoMapperImpl mapper = new MergeResultToDtoMapperImpl(); + + private MergeResource mergeResource; + + @BeforeEach + void init() { + mergeResource = new MergeResource(serviceFactory, mapper); + super.mergeResource = Providers.of(mergeResource); + dispatcher = DispatcherMock.createDispatcher(getRepositoryRootResource()); + } + + @Test + void shouldHandleIllegalInput() throws Exception { + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand_invalid.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + System.out.println(response.getContentAsString()); + } + + @Nested + class ExecutingMergeCommand { + + @BeforeEach + void initRepository() { + when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); + when(repositoryService.getMergeCommand()).thenReturn(mergeCommandBuilder); + } + + @Test + void shouldHandleSuccessfulMerge() throws Exception { + when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.success()); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL) + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void shouldHandleFailedMerge() throws Exception { + when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.failure(asList("file1", "file2"))); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL) + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("file1", "file2"); + } + + @Test + void shouldHandleSuccessfulDryRun() throws Exception { + when(mergeCommand.dryRun(any())).thenReturn(new MergeDryRunCommandResult(true)); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void shouldHandleFailedDryRun() throws Exception { + when(mergeCommand.dryRun(any())).thenReturn(new MergeDryRunCommandResult(false)); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index 3d3b28ae51..b2daf0536c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java @@ -21,6 +21,8 @@ public abstract class RepositoryTestBase { protected Provider modificationsRootResource; protected Provider fileHistoryRootResource; protected Provider repositoryCollectionResource; + protected Provider incomingRootResource; + protected Provider mergeResource; RepositoryRootResource getRepositoryRootResource() { @@ -36,7 +38,9 @@ public abstract class RepositoryTestBase { permissionRootResource, diffRootResource, modificationsRootResource, - fileHistoryRootResource)), repositoryCollectionResource); + fileHistoryRootResource, + incomingRootResource, + mergeResource)), repositoryCollectionResource); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index c2dc685306..435d8b4673 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -20,6 +20,7 @@ public class ResourceLinksMock { when(resourceLinks.group()).thenReturn(new ResourceLinks.GroupLinks(uriInfo)); when(resourceLinks.groupCollection()).thenReturn(new ResourceLinks.GroupCollectionLinks(uriInfo)); when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(uriInfo)); + when(resourceLinks.incoming()).thenReturn(new ResourceLinks.IncomingLinks(uriInfo)); when(resourceLinks.repositoryCollection()).thenReturn(new ResourceLinks.RepositoryCollectionLinks(uriInfo)); when(resourceLinks.tag()).thenReturn(new ResourceLinks.TagCollectionLinks(uriInfo)); when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo)); @@ -37,6 +38,7 @@ public class ResourceLinksMock { when(resourceLinks.uiPlugin()).thenReturn(new ResourceLinks.UIPluginLinks(uriInfo)); when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(uriInfo)); when(resourceLinks.index()).thenReturn(new ResourceLinks.IndexLinks(uriInfo)); + when(resourceLinks.merge()).thenReturn(new ResourceLinks.MergeLinks(uriInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/java/sonia/scm/security/AccessTokenCookieIssuerTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAccessTokenCookieIssuerTest.java similarity index 93% rename from scm-webapp/src/test/java/sonia/scm/security/AccessTokenCookieIssuerTest.java rename to scm-webapp/src/test/java/sonia/scm/security/DefaultAccessTokenCookieIssuerTest.java index 03cf174226..9c80cfc67b 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/AccessTokenCookieIssuerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAccessTokenCookieIssuerTest.java @@ -20,11 +20,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) -public class AccessTokenCookieIssuerTest { +public class DefaultAccessTokenCookieIssuerTest { private ScmConfiguration configuration; - private AccessTokenCookieIssuer issuer; + private DefaultAccessTokenCookieIssuer issuer; @Mock private HttpServletRequest request; @@ -41,7 +41,7 @@ public class AccessTokenCookieIssuerTest { @Before public void setUp() { configuration = new ScmConfiguration(); - issuer = new AccessTokenCookieIssuer(configuration); + issuer = new DefaultAccessTokenCookieIssuer(configuration); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java index c4f281537e..cce3fea2b1 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java @@ -44,12 +44,16 @@ import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.store.ConfigurationEntryStore; import sonia.scm.store.ConfigurationEntryStoreFactory; +import java.util.Random; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.in; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -99,10 +103,11 @@ public class SecureKeyResolverTest * Method description * */ - @Test(expected = IllegalStateException.class) + @Test public void testResolveSigningKeyBytesWithoutKey() { - resolver.resolveSigningKeyBytes(null, Jwts.claims().setSubject("test")); + byte[] bytes = resolver.resolveSigningKeyBytes(null, Jwts.claims().setSubject("test")); + assertThat(bytes[0]).isEqualTo((byte) 42); } /** @@ -132,7 +137,9 @@ public class SecureKeyResolverTest assertThat(storeParameters.getType()).isEqualTo(SecureKey.class); return true; }))).thenReturn(store); - resolver = new SecureKeyResolver(factory); + Random random = mock(Random.class); + doAnswer(invocation -> ((byte[]) invocation.getArguments()[0])[0] = 42).when(random).nextBytes(any()); + resolver = new SecureKeyResolver(factory, random); } //~--- fields --------------------------------------------------------------- diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json new file mode 100644 index 0000000000..dde0b6a413 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json @@ -0,0 +1,4 @@ +{ + "sourceRevision": "source", + "targetRevision": "target" +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json new file mode 100644 index 0000000000..b2d1e5ab3f --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json @@ -0,0 +1,4 @@ +{ + "sourceRevision": "", + "targetRevision": "target" +}