Merged in feature/merge_endpoint (pull request #122)

Feature merge endpoint
This commit is contained in:
Sebastian Sdorra
2018-12-10 12:20:12 +00:00
24 changed files with 494 additions and 35 deletions

View File

@@ -15,7 +15,7 @@ import sonia.scm.repository.spi.MergeCommandRequest;
*
* To actually merge <code>feature_branch</code> into <code>integration_branch</code> do this:
* <pre><code>
* 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:
* <pre><code>
* repositoryService.gerMergeCommand()
* repositoryService.getMergeCommand()
* .setBranchToMerge("feature_branch")
* .setTargetBranch("integration_branch")
* .dryRun()

View File

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

View File

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

View File

@@ -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<String> SCHEMES = ImmutableSet.of("scm");
private static final Set<String> 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> diffRootResource;
private final Provider<ModificationsRootResource> modificationsRootResource;
private final Provider<FileHistoryRootResource> fileHistoryRootResource;
private final Provider<MergeResource> mergeResource;
@Inject
public RepositoryResource(
@@ -56,8 +56,8 @@ public class RepositoryResource {
Provider<PermissionRootResource> permissionRootResource,
Provider<DiffRootResource> diffRootResource,
Provider<ModificationsRootResource> modificationsRootResource,
Provider<FileHistoryRootResource> fileHistoryRootResource
) {
Provider<FileHistoryRootResource> fileHistoryRootResource,
Provider<MergeResource> 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<Response> handleNotArchived(Throwable throwable) {
if (throwable instanceof RepositoryIsNotArchivedException) {
return Optional.of(Response.status(Response.Status.PRECONDITION_FAILED).build());

View File

@@ -55,6 +55,10 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
if (repositoryService.isSupported(Command.BRANCHES)) {
linksBuilder.single(link("branches", resourceLinks.branchCollection().self(target.getNamespace(), target.getName())));
}
if (repositoryService.isSupported(Command.MERGE)) {
linksBuilder.single(link("merge", resourceLinks.merge().merge(target.getNamespace(), target.getName())));
linksBuilder.single(link("mergeDryRun", resourceLinks.merge().dryRun(target.getNamespace(), target.getName())));
}
}
linksBuilder.single(link("changesets", resourceLinks.changeset().all(target.getNamespace(), target.getName())));
linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(target.getNamespace(), target.getName())));

View File

@@ -541,4 +541,23 @@ class ResourceLinks {
}
}
public MergeLinks merge() {
return new MergeLinks(scmPathInfoStore.get());
}
static class MergeLinks {
private final LinkBuilder mergeLinkBuilder;
MergeLinks(ScmPathInfo pathInfo) {
this.mergeLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, MergeResource.class);
}
String merge(String namespace, String name) {
return mergeLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("merge").parameters().method("merge").parameters().href();
}
String dryRun(String namespace, String name) {
return mergeLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("merge").parameters().method("dryRun").parameters().href();
}
}
}

View File

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

View File

@@ -21,6 +21,7 @@ public abstract class RepositoryTestBase {
protected Provider<ModificationsRootResource> modificationsRootResource;
protected Provider<FileHistoryRootResource> fileHistoryRootResource;
protected Provider<RepositoryCollectionResource> repositoryCollectionResource;
protected Provider<MergeResource> mergeResource;
RepositoryRootResource getRepositoryRootResource() {
@@ -36,7 +37,8 @@ public abstract class RepositoryTestBase {
permissionRootResource,
diffRootResource,
modificationsRootResource,
fileHistoryRootResource)), repositoryCollectionResource);
fileHistoryRootResource,
mergeResource)), repositoryCollectionResource);
}

View File

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

View File

@@ -0,0 +1,4 @@
{
"sourceRevision": "source",
"targetRevision": "target"
}

View File

@@ -0,0 +1,4 @@
{
"sourceRevision": "",
"targetRevision": "target"
}