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 c2dc706bf5..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
@@ -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-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/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/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/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/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-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js index 3fd90a2d7a..8c4bde6a72 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,24 +16,26 @@ function handleStatusCode(response: Response) { if (!response.ok) { switch (response.status) { case 401: - return throwErrorWithMessage(response, UNAUTHORIZED_ERROR_MESSAGE); + return throwError(response, UNAUTHORIZED_ERROR); case 404: - return throwErrorWithMessage(response, NOT_FOUND_ERROR_MESSAGE); + return throwError(response, NOT_FOUND_ERROR); + case 409: + return throwError(response, CONFLICT_ERROR); default: - return throwErrorWithMessage(response, "server returned status code " + response.status); + return throwError(response, new Error("server returned status code " + response.status)); } } return response; } -function throwErrorWithMessage(response: Response, message: string) { +function throwError(response: Response, err: Error) { return response.json().then( json => { throw Error(json.message); }, () => { - throw Error(message); + throw err; } ); } diff --git a/scm-ui-components/packages/ui-components/src/index.js b/scm-ui-components/packages/ui-components/src/index.js index 334e0a696c..b064a36145 100644 --- a/scm-ui-components/packages/ui-components/src/index.js +++ b/scm-ui-components/packages/ui-components/src/index.js @@ -25,7 +25,7 @@ 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 "./buttons"; export * from "./config"; 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-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/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 a964819414..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,7 @@ public class RepositoryResource { private final Provider diffRootResource; private final Provider modificationsRootResource; private final Provider fileHistoryRootResource; + private final Provider mergeResource; private final Provider incomingRootResource; @Inject @@ -58,7 +58,8 @@ public class RepositoryResource { Provider diffRootResource, Provider modificationsRootResource, Provider fileHistoryRootResource, - Provider incomingRootResource) { + Provider incomingRootResource, + Provider mergeResource) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; @@ -72,6 +73,7 @@ public class RepositoryResource { this.diffRootResource = diffRootResource; this.modificationsRootResource = modificationsRootResource; this.fileHistoryRootResource = fileHistoryRootResource; + this.mergeResource = mergeResource; this.incomingRootResource = incomingRootResource; } @@ -206,6 +208,9 @@ public class RepositoryResource { return incomingRootResource.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/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index e5d812cffb..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 @@ -60,6 +60,10 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper fileHistoryRootResource; protected Provider repositoryCollectionResource; protected Provider incomingRootResource; + protected Provider mergeResource; RepositoryRootResource getRepositoryRootResource() { @@ -38,7 +39,8 @@ public abstract class RepositoryTestBase { diffRootResource, modificationsRootResource, fileHistoryRootResource, - incomingRootResource)), repositoryCollectionResource); + 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 1660b47f02..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 @@ -38,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/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" +}