From 7900b94011f6afaa5c60034ed08583e330d837fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 5 Dec 2018 13:52:14 +0100 Subject: [PATCH] Add resource for merges --- .../repository/api/MergeCommandBuilder.java | 4 +- .../scm/repository/api/RepositoryService.java | 4 +- .../main/java/sonia/scm/web/VndMediaType.java | 2 + .../scm/api/v2/resources/MapperModule.java | 2 + .../scm/api/v2/resources/MergeCommandDto.java | 14 ++ .../scm/api/v2/resources/MergeResource.java | 71 +++++++++ .../scm/api/v2/resources/MergeResultDto.java | 12 ++ .../v2/resources/MergeResultToDtoMapper.java | 9 ++ .../api/v2/resources/RepositoryResource.java | 12 +- .../api/v2/resources/MergeResourceTest.java | 150 ++++++++++++++++++ .../api/v2/resources/RepositoryTestBase.java | 4 +- .../sonia/scm/api/v2/mergeCommand.json | 4 + .../scm/api/v2/mergeCommand_invalid.json | 4 + 13 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json 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..c76fe524fd 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
@@ -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/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-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..5af32cedad
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java
@@ -0,0 +1,71 @@
+package sonia.scm.api.v2.resources;
+
+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
+  @Produces(VndMediaType.MERGE_RESULT)
+  @Consumes(VndMediaType.MERGE_COMMAND)
+  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/")
+  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..c4f76d0167 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,7 @@ public class RepositoryResource {
   private final Provider diffRootResource;
   private final Provider modificationsRootResource;
   private final Provider fileHistoryRootResource;
+  private final Provider mergeResource;
 
   @Inject
   public RepositoryResource(
@@ -56,8 +56,8 @@ public class RepositoryResource {
     Provider permissionRootResource,
     Provider diffRootResource,
     Provider modificationsRootResource,
-    Provider fileHistoryRootResource
-  ) {
+    Provider fileHistoryRootResource,
+    Provider mergeResource) {
     this.dtoToRepositoryMapper = dtoToRepositoryMapper;
     this.manager = manager;
     this.repositoryToDtoMapper = repositoryToDtoMapper;
@@ -71,6 +71,7 @@ public class RepositoryResource {
     this.diffRootResource = diffRootResource;
     this.modificationsRootResource = modificationsRootResource;
     this.fileHistoryRootResource = fileHistoryRootResource;
+    this.mergeResource = mergeResource;
   }
 
   /**
@@ -194,9 +195,12 @@ public class RepositoryResource {
     return permissionRootResource.get();
   }
 
- @Path("modifications/")
+  @Path("modifications/")
   public ModificationsRootResource modifications() {return modificationsRootResource.get(); }
 
+  @Path("merge/")
+  public MergeResource merge() {return mergeResource.get(); }
+
   private Optional handleNotArchived(Throwable throwable) {
     if (throwable instanceof RepositoryIsNotArchivedException) {
       return Optional.of(Response.status(Response.Status.PRECONDITION_FAILED).build());
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..0ccb923e45 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,7 @@ public abstract class RepositoryTestBase {
   protected Provider modificationsRootResource;
   protected Provider fileHistoryRootResource;
   protected Provider repositoryCollectionResource;
+  protected Provider mergeResource;
 
 
   RepositoryRootResource getRepositoryRootResource() {
@@ -36,7 +37,8 @@ public abstract class RepositoryTestBase {
       permissionRootResource,
       diffRootResource,
       modificationsRootResource,
-      fileHistoryRootResource)), repositoryCollectionResource);
+      fileHistoryRootResource,
+      mergeResource)), repositoryCollectionResource);
   }
 
 
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"
+}