Merged in feature/init_new_repo (pull request #394)

Feature/init new repo
This commit is contained in:
Sebastian Sdorra
2020-01-21 07:13:01 +00:00
19 changed files with 615 additions and 39 deletions

View File

@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set individual page title - Set individual page title
- Copy on write - Copy on write
- A new repository can be initialized with a branch (for git and mercurial) and custom files (README.md on default)
### Changed ### Changed
- Stop fetching commits when it takes too long - Stop fetching commits when it takes too long

View File

@@ -71,12 +71,25 @@ public final class Priorities
* *
* @return sorted class list * @return sorted class list
*/ */
public static <T> List<Class<? extends T>> sort( public static <T> List<Class<? extends T>> sort(Iterable<Class<? extends T>> unordered)
Iterable<Class<? extends T>> unordered)
{ {
return new PriorityOrdering<T>().sortedCopy(unordered); return new PriorityOrdering<T>().sortedCopy(unordered);
} }
/**
* Returns a list of instances sorted by priority.
*
* @param <T> type of class
* @param unordered unordered instances
*
* @return sorted instance list
*/
public static <T> List<T> sortInstances(Iterable<T> unordered)
{
return new PriorityInstanceOrdering<T>().sortedCopy(unordered);
}
//~--- get methods ---------------------------------------------------------- //~--- get methods ----------------------------------------------------------
/** /**
@@ -125,4 +138,28 @@ public final class Priorities
return Ints.compare(getPriority(left), getPriority(right)); return Ints.compare(getPriority(left), getPriority(right));
} }
} }
/**
* {@link Ordering} which orders instances by priority.
*
* @param <T> type of instance
*/
public static class PriorityInstanceOrdering<T> extends Ordering<T>
{
/**
* Compares the left instance with the right instance.
*
*
* @param left left instance
* @param right right instance
*
* @return compare value
*/
@Override
public int compare(T left, T right)
{
return Ints.compare(getPriority(left.getClass()), getPriority(right.getClass()));
}
}
} }

View File

@@ -0,0 +1,72 @@
package sonia.scm.repository;
import com.google.common.io.ByteSource;
import sonia.scm.plugin.ExtensionPoint;
import java.io.IOException;
import java.io.InputStream;
/**
* Use this {@link RepositoryContentInitializer} to create new files with custom content
* which will be included in the initial commit of the new repository
*/
@ExtensionPoint
public interface RepositoryContentInitializer {
/**
*
* @param context add content to this context in order to commit files in the initial repository commit
* @throws IOException
*/
void initialize(InitializerContext context) throws IOException;
/**
* Use this {@link InitializerContext} to create new files on repository initialization
* which will be included in the first commit
*/
interface InitializerContext {
/**
* @return repository to which this initializerContext belongs to
*/
Repository getRepository();
/**
* create new file which will be included in initial repository commit
* @param path path of new file
* @return
*/
CreateFile create(String path);
}
/**
* Use this to apply content to new files which should be committed on repository initialization
*/
interface CreateFile {
/**
* Applies content to new file
* @param content content of file as string
* @return {@link InitializerContext}
* @throws IOException
*/
InitializerContext from(String content) throws IOException;
/**
* Applies content to new file
* @param input content of file as input stream
* @return {@link InitializerContext}
* @throws IOException
*/
InitializerContext from(InputStream input) throws IOException;
/**
* Applies content to new file
* @param byteSource content of file as byte source
* @return {@link InitializerContext}
* @throws IOException
*/
InitializerContext from(ByteSource byteSource) throws IOException;
}
}

View File

@@ -102,7 +102,7 @@ public class ModifyCommandBuilder {
public String execute() { public String execute() {
AuthorUtil.setAuthorIfNotAvailable(request); AuthorUtil.setAuthorIfNotAvailable(request);
try { try {
Preconditions.checkArgument(request.isValid(), "commit message, branch and at least one request are required"); Preconditions.checkArgument(request.isValid(), "commit message and at least one request are required");
return command.execute(request); return command.execute(request);
} finally { } finally {
try { try {

View File

@@ -98,6 +98,21 @@ public class PrioritiesTest
assertThat(cls, contains(B.class, C.class, A.class, D.class)); assertThat(cls, contains(B.class, C.class, A.class, D.class));
} }
@Test
@SuppressWarnings("unchecked")
public void shouldSortInstances()
{
A a = new A();
B b = new B();
C c = new C();
D d = new D();
List<?> instances = ImmutableList.of(a, b, c, d);
instances = Priorities.sortInstances(instances);
assertThat(instances, contains(b, c, a, d));
}
//~--- inner classes -------------------------------------------------------- //~--- inner classes --------------------------------------------------------
/** /**

View File

@@ -70,6 +70,18 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase {
} }
} }
@Test
public void shouldCheckoutDefaultBranch() {
SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider);
try (WorkingCopy<Repository, Repository> workingCopy = factory.createWorkingCopy(createContext(), null)) {
assertThat(new File(workingCopy.getWorkingRepository().getWorkTree(), "a.txt"))
.exists()
.isFile()
.hasContent("a\nline for blame");
}
}
@Test @Test
public void cloneFromPoolShouldNotBeReused() { public void cloneFromPoolShouldNotBeReused() {
SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider);

View File

@@ -21,7 +21,8 @@
"nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.", "nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.",
"typeHelpText": "Der Typ des Repository (Mercurial, Git oder Subversion).", "typeHelpText": "Der Typ des Repository (Mercurial, Git oder Subversion).",
"contactHelpText": "E-Mail Adresse der Person, die für das Repository verantwortlich ist.", "contactHelpText": "E-Mail Adresse der Person, die für das Repository verantwortlich ist.",
"descriptionHelpText": "Eine kurze Beschreibung des Repository." "descriptionHelpText": "Eine kurze Beschreibung des Repository.",
"initializeRepository": "Erstellt einen ersten Branch und committet eine README.md."
}, },
"repositoryRoot": { "repositoryRoot": {
"errorTitle": "Fehler", "errorTitle": "Fehler",
@@ -97,7 +98,8 @@
}, },
"repositoryForm": { "repositoryForm": {
"subtitle": "Repository bearbeiten", "subtitle": "Repository bearbeiten",
"submit": "Speichern" "submit": "Speichern",
"initializeRepository": "Repository initiieren"
}, },
"sources": { "sources": {
"file-tree": { "file-tree": {

View File

@@ -21,7 +21,8 @@
"nameHelpText": "The name of the repository. This name will be part of the repository url.", "nameHelpText": "The name of the repository. This name will be part of the repository url.",
"typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).", "typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).",
"contactHelpText": "Email address of the person who is responsible for this repository.", "contactHelpText": "Email address of the person who is responsible for this repository.",
"descriptionHelpText": "A short description of the repository." "descriptionHelpText": "A short description of the repository.",
"initializeRepository": "Creates a initial branch and commit a basic README.md."
}, },
"repositoryRoot": { "repositoryRoot": {
"errorTitle": "Error", "errorTitle": "Error",
@@ -97,7 +98,8 @@
}, },
"repositoryForm": { "repositoryForm": {
"subtitle": "Edit Repository", "subtitle": "Edit Repository",
"submit": "Save" "submit": "Save",
"initializeRepository": "Initialize repository"
}, },
"sources": { "sources": {
"file-tree": { "file-tree": {

View File

@@ -21,7 +21,8 @@
"nameHelpText": "El nombre del repositorio. Este nombre formará parte de la URL del repositorio.", "nameHelpText": "El nombre del repositorio. Este nombre formará parte de la URL del repositorio.",
"typeHelpText": "El tipo del repositorio (Mercurial, Git or Subversion).", "typeHelpText": "El tipo del repositorio (Mercurial, Git or Subversion).",
"contactHelpText": "Dirección del correo electrónico de la persona responsable del repositorio.", "contactHelpText": "Dirección del correo electrónico de la persona responsable del repositorio.",
"descriptionHelpText": "Breve descripción del repositorio." "descriptionHelpText": "Breve descripción del repositorio.",
"initializeRepository": "Creates a initial branch and commit a basic README.md."
}, },
"repositoryRoot": { "repositoryRoot": {
"errorTitle": "Error", "errorTitle": "Error",
@@ -97,7 +98,8 @@
}, },
"repositoryForm": { "repositoryForm": {
"subtitle": "Editar repositorio", "subtitle": "Editar repositorio",
"submit": "Guardar" "submit": "Guardar",
"initializeRepository": "Initialize repository"
}, },
"sources": { "sources": {
"file-tree": { "file-tree": {

View File

@@ -1,12 +1,27 @@
import React from "react"; import React from "react";
import styled from "styled-components";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Repository, RepositoryType } from "@scm-manager/ui-types"; import { Repository, RepositoryType } from "@scm-manager/ui-types";
import { InputField, Level, Select, SubmitButton, Subtitle, Textarea } from "@scm-manager/ui-components"; import { Checkbox, Level, InputField, Select, SubmitButton, Subtitle, Textarea } from "@scm-manager/ui-components";
import * as validator from "./repositoryValidation"; import * as validator from "./repositoryValidation";
const CheckboxWrapper = styled.div`
margin-top: 2em;
flex: 1;
`;
const SelectWrapper = styled.div`
flex: 1;
`;
const SpaceBetween = styled.div`
display: flex;
justify-content: space-between;
`;
type Props = WithTranslation & { type Props = WithTranslation & {
submitForm: (p: Repository) => void; submitForm: (repo: Repository, shouldInit: boolean) => void;
repository?: Repository; repository?: Repository;
repositoryTypes?: RepositoryType[]; repositoryTypes?: RepositoryType[];
namespaceStrategy?: string; namespaceStrategy?: string;
@@ -15,6 +30,7 @@ type Props = WithTranslation & {
type State = { type State = {
repository: Repository; repository: Repository;
initRepository: boolean;
namespaceValidationError: boolean; namespaceValidationError: boolean;
nameValidationError: boolean; nameValidationError: boolean;
contactValidationError: boolean; contactValidationError: boolean;
@@ -35,6 +51,7 @@ class RepositoryForm extends React.Component<Props, State> {
description: "", description: "",
_links: {} _links: {}
}, },
initRepository: false,
namespaceValidationError: false, namespaceValidationError: false,
nameValidationError: false, nameValidationError: false,
contactValidationError: false contactValidationError: false
@@ -71,7 +88,7 @@ class RepositoryForm extends React.Component<Props, State> {
submit = (event: Event) => { submit = (event: Event) => {
event.preventDefault(); event.preventDefault();
if (this.isValid()) { if (this.isValid()) {
this.props.submitForm(this.state.repository); this.props.submitForm(this.state.repository, this.state.initRepository);
} }
}; };
@@ -83,6 +100,12 @@ class RepositoryForm extends React.Component<Props, State> {
return !!this.props.repository && !!this.props.repository._links.update; return !!this.props.repository && !!this.props.repository._links.update;
}; };
toggleInitCheckbox = () => {
this.setState({
initRepository: !this.state.initRepository
});
};
render() { render() {
const { loading, t } = this.props; const { loading, t } = this.props;
const repository = this.state.repository; const repository = this.state.repository;
@@ -175,6 +198,8 @@ class RepositoryForm extends React.Component<Props, State> {
errorMessage={t("validation.name-invalid")} errorMessage={t("validation.name-invalid")}
helpText={t("help.nameHelpText")} helpText={t("help.nameHelpText")}
/> />
<SpaceBetween>
<SelectWrapper>
<Select <Select
label={t("repository.type")} label={t("repository.type")}
onChange={this.handleTypeChange} onChange={this.handleTypeChange}
@@ -182,6 +207,16 @@ class RepositoryForm extends React.Component<Props, State> {
options={this.createSelectOptions(repositoryTypes)} options={this.createSelectOptions(repositoryTypes)}
helpText={t("help.typeHelpText")} helpText={t("help.typeHelpText")}
/> />
</SelectWrapper>
<CheckboxWrapper>
<Checkbox
label={t("repositoryForm.initializeRepository")}
checked={this.state.initRepository}
onChange={this.toggleInitCheckbox}
helpText={t("help.initializeRepository")}
/>
</CheckboxWrapper>
</SpaceBetween>
</> </>
); );
} }

View File

@@ -31,7 +31,7 @@ type Props = WithTranslation & {
// dispatch functions // dispatch functions
fetchNamespaceStrategiesIfNeeded: () => void; fetchNamespaceStrategiesIfNeeded: () => void;
fetchRepositoryTypesIfNeeded: () => void; fetchRepositoryTypesIfNeeded: () => void;
createRepo: (link: string, p2: Repository, callback: (repo: Repository) => void) => void; createRepo: (link: string, repository: Repository, initRepository: boolean, callback: (repo: Repository) => void) => void;
resetForm: () => void; resetForm: () => void;
// context props // context props
@@ -67,8 +67,8 @@ class Create extends React.Component<Props> {
repositoryTypes={repositoryTypes} repositoryTypes={repositoryTypes}
loading={createLoading} loading={createLoading}
namespaceStrategy={namespaceStrategies.current} namespaceStrategy={namespaceStrategies.current}
submitForm={repo => { submitForm={(repo, initRepository) => {
createRepo(repoLink, repo, (repo: Repository) => this.repoCreated(repo)); createRepo(repoLink, repo, initRepository, (repo: Repository) => this.repoCreated(repo));
}} }}
/> />
</Page> </Page>
@@ -102,8 +102,8 @@ const mapDispatchToProps = (dispatch: any) => {
fetchNamespaceStrategiesIfNeeded: () => { fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded()); dispatch(fetchNamespaceStrategiesIfNeeded());
}, },
createRepo: (link: string, repository: Repository, callback: () => void) => { createRepo: (link: string, repository: Repository, initRepository: boolean, callback: () => void) => {
dispatch(createRepo(link, repository, callback)); dispatch(createRepo(link, repository, initRepository, callback));
}, },
resetForm: () => { resetForm: () => {
dispatch(createRepoReset()); dispatch(createRepoReset());

View File

@@ -430,7 +430,7 @@ describe("repos fetch", () => {
}; };
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createRepo(URL, slartiFjords, callback)).then(() => { return store.dispatch(createRepo(URL, slartiFjords, false, callback)).then(() => {
expect(callMe).toBe("yeah"); expect(callMe).toBe("yeah");
}); });
}); });
@@ -441,7 +441,7 @@ describe("repos fetch", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createRepo(URL, slartiFjords)).then(() => { return store.dispatch(createRepo(URL, slartiFjords, false)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_REPO_PENDING); expect(actions[0].type).toEqual(CREATE_REPO_PENDING);
expect(actions[1].type).toEqual(CREATE_REPO_FAILURE); expect(actions[1].type).toEqual(CREATE_REPO_FAILURE);

View File

@@ -155,11 +155,12 @@ export function fetchRepoFailure(namespace: string, name: string, error: Error):
// create repo // create repo
export function createRepo(link: string, repository: Repository, callback?: (repo: Repository) => void) { export function createRepo(link: string, repository: Repository, initRepository: boolean, callback?: (repo: Repository) => void) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(createRepoPending()); dispatch(createRepoPending());
const repoLink = initRepository ? link + "?initialize=true" : link;
return apiClient return apiClient
.post(link, repository, CONTENT_TYPE) .post(repoLink, repository, CONTENT_TYPE)
.then(response => { .then(response => {
const location = response.headers.get("Location"); const location = response.headers.get("Location");
dispatch(createRepoSuccess()); dispatch(createRepoSuccess());

View File

@@ -7,6 +7,7 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import com.webcohesion.enunciate.metadata.rs.TypeHint;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryInitializer;
import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryPermission;
import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchRequest;
@@ -24,7 +25,7 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate; import java.util.function.Predicate;
import static com.google.common.base.Strings.isNullOrEmpty; import static com.google.common.base.Strings.isNullOrEmpty;
@@ -38,13 +39,15 @@ public class RepositoryCollectionResource {
private final RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper; private final RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper;
private final RepositoryDtoToRepositoryMapper dtoToRepositoryMapper; private final RepositoryDtoToRepositoryMapper dtoToRepositoryMapper;
private final ResourceLinks resourceLinks; private final ResourceLinks resourceLinks;
private final RepositoryInitializer repositoryInitializer;
@Inject @Inject
public RepositoryCollectionResource(RepositoryManager manager, RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper, RepositoryDtoToRepositoryMapper dtoToRepositoryMapper, ResourceLinks resourceLinks) { public RepositoryCollectionResource(RepositoryManager manager, RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper, RepositoryDtoToRepositoryMapper dtoToRepositoryMapper, ResourceLinks resourceLinks, RepositoryInitializer repositoryInitializer) {
this.adapter = new CollectionResourceManagerAdapter<>(manager, Repository.class); this.adapter = new CollectionResourceManagerAdapter<>(manager, Repository.class);
this.repositoryCollectionToDtoMapper = repositoryCollectionToDtoMapper; this.repositoryCollectionToDtoMapper = repositoryCollectionToDtoMapper;
this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.dtoToRepositoryMapper = dtoToRepositoryMapper;
this.resourceLinks = resourceLinks; this.resourceLinks = resourceLinks;
this.repositoryInitializer = repositoryInitializer;
} }
/** /**
@@ -98,10 +101,18 @@ public class RepositoryCollectionResource {
}) })
@TypeHint(TypeHint.NO_CONTENT.class) @TypeHint(TypeHint.NO_CONTENT.class)
@ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created repository")) @ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created repository"))
public Response create(@Valid RepositoryDto repository) { public Response create(@Valid RepositoryDto repository, @QueryParam("initialize") boolean initialize) {
return adapter.create(repository, AtomicReference<Repository> reference = new AtomicReference<>();
Response response = adapter.create(repository,
() -> createModelObjectFromDto(repository), () -> createModelObjectFromDto(repository),
r -> resourceLinks.repository().self(r.getNamespace(), r.getName())); r -> {
reference.set(r);
return resourceLinks.repository().self(r.getNamespace(), r.getName());
});
if (initialize) {
repositoryInitializer.initialize(reference.get());
}
return response;
} }
private Repository createModelObjectFromDto(@Valid RepositoryDto repositoryDto) { private Repository createModelObjectFromDto(@Valid RepositoryDto repositoryDto) {

View File

@@ -0,0 +1,23 @@
package sonia.scm.repository;
import com.google.common.base.Strings;
import sonia.scm.Priority;
import sonia.scm.plugin.Extension;
import java.io.IOException;
@Extension
@Priority(1) // should always be the first, so that plugins can overwrite the readme.md
public class ReadmeRepositoryContentInitializer implements RepositoryContentInitializer {
@Override
public void initialize(InitializerContext context) throws IOException {
Repository repository = context.getRepository();
String content = "# " + repository.getName();
String description = repository.getDescription();
if (!Strings.isNullOrEmpty(description)) {
content += "\n\n" + description;
}
context.create("README.md").from(content);
}
}

View File

@@ -0,0 +1,101 @@
package sonia.scm.repository;
import com.google.common.io.ByteSource;
import com.google.common.io.CharSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.Priorities;
import sonia.scm.repository.api.ModifyCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Set;
@Singleton
public class RepositoryInitializer {
private static final Logger LOG = LoggerFactory.getLogger(RepositoryInitializer.class);
private final RepositoryServiceFactory serviceFactory;
private final Iterable<RepositoryContentInitializer> contentInitializers;
@Inject
public RepositoryInitializer(RepositoryServiceFactory serviceFactory, Set<RepositoryContentInitializer> contentInitializerSet) {
this.serviceFactory = serviceFactory;
this.contentInitializers = Priorities.sortInstances(contentInitializerSet);
}
public void initialize(Repository repository) {
try (RepositoryService service = serviceFactory.create(repository)) {
ModifyCommandBuilder modifyCommandBuilder = service.getModifyCommand();
InitializerContextImpl initializerContext = new InitializerContextImpl(repository, modifyCommandBuilder);
for (RepositoryContentInitializer initializer : contentInitializers) {
initializer.initialize(initializerContext);
}
modifyCommandBuilder.setCommitMessage("initialize repository");
String revision = modifyCommandBuilder.execute();
LOG.info("initialized repository {} as revision {}", repository.getNamespaceAndName(), revision);
} catch (IOException e) {
throw new InternalRepositoryException(repository, "failed to initialize repository", e);
}
}
private class InitializerContextImpl implements RepositoryContentInitializer.InitializerContext {
private final Repository repository;
private final ModifyCommandBuilder builder;
InitializerContextImpl(Repository repository, ModifyCommandBuilder builder) {
this.repository = repository;
this.builder = builder;
}
@Override
public Repository getRepository() {
return repository;
}
@Override
public RepositoryContentInitializer.CreateFile create(String path) {
return new CreateFileImpl(this, builder.createFile(path).setOverwrite(true));
}
}
private class CreateFileImpl implements RepositoryContentInitializer.CreateFile {
private final RepositoryContentInitializer.InitializerContext initializerContext;
private final ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader;
CreateFileImpl(RepositoryContentInitializer.InitializerContext initializerContext, ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader) {
this.initializerContext = initializerContext;
this.contentLoader = contentLoader;
}
@Override
public RepositoryContentInitializer.InitializerContext from(String content) throws IOException {
return from(CharSource.wrap(content).asByteSource(StandardCharsets.UTF_8));
}
@Override
public RepositoryContentInitializer.InitializerContext from(InputStream input) throws IOException {
contentLoader.withData(input);
return initializerContext;
}
@Override
public RepositoryContentInitializer.InitializerContext from(ByteSource byteSource) throws IOException {
contentLoader.withData(byteSource);
return initializerContext;
}
}
}

View File

@@ -18,6 +18,7 @@ import org.mockito.Mock;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryInitializer;
import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -76,6 +77,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
private ScmPathInfoStore scmPathInfoStore; private ScmPathInfoStore scmPathInfoStore;
@Mock @Mock
private ScmPathInfo uriInfo; private ScmPathInfo uriInfo;
@Mock
private RepositoryInitializer repositoryInitializer;
@Captor @Captor
private ArgumentCaptor<Predicate<Repository>> filterCaptor; private ArgumentCaptor<Predicate<Repository>> filterCaptor;
@@ -95,7 +98,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
super.dtoToRepositoryMapper = dtoToRepositoryMapper; super.dtoToRepositoryMapper = dtoToRepositoryMapper;
super.manager = repositoryManager; super.manager = repositoryManager;
RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks); RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks);
super.repositoryCollectionResource = Providers.of(new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks)); super.repositoryCollectionResource = Providers.of(new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks, repositoryInitializer));
dispatcher.addSingletonResource(getRepositoryRootResource()); dispatcher.addSingletonResource(getRepositoryRootResource());
when(serviceFactory.create(any(Repository.class))).thenReturn(service); when(serviceFactory.create(any(Repository.class))).thenReturn(service);
when(scmPathInfoStore.get()).thenReturn(uriInfo); when(scmPathInfoStore.get()).thenReturn(uriInfo);
@@ -288,6 +291,32 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
assertEquals(HttpServletResponse.SC_CREATED, response.getStatus()); assertEquals(HttpServletResponse.SC_CREATED, response.getStatus());
assertEquals("/v2/repositories/otherspace/repo", response.getOutputHeaders().get("Location").get(0).toString()); assertEquals("/v2/repositories/otherspace/repo", response.getOutputHeaders().get("Location").get(0).toString());
verify(repositoryManager).create(any(Repository.class)); verify(repositoryManager).create(any(Repository.class));
verify(repositoryInitializer, never()).initialize(any(Repository.class));
}
@Test
public void shouldCreateNewRepositoryAndInitialize() throws Exception {
when(repositoryManager.create(any())).thenAnswer(invocation -> invocation.getArgument(0));
URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json");
byte[] repositoryJson = Resources.toByteArray(url);
MockHttpRequest request = MockHttpRequest
.post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "?initialize=true")
.contentType(VndMediaType.REPOSITORY)
.content(repositoryJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_CREATED, response.getStatus());
ArgumentCaptor<Repository> captor = ArgumentCaptor.forClass(Repository.class);
verify(repositoryInitializer).initialize(captor.capture());
Repository repository = captor.getValue();
assertEquals("space", repository.getNamespace());
assertEquals("repo", repository.getName());
} }
@Test @Test

View File

@@ -0,0 +1,52 @@
package sonia.scm.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReadmeRepositoryContentInitializerTest {
@Mock
private RepositoryContentInitializer.InitializerContext context;
@Mock
private RepositoryContentInitializer.CreateFile createFile;
private Repository repository;
private ReadmeRepositoryContentInitializer initializer = new ReadmeRepositoryContentInitializer();
@BeforeEach
void setUpContext() {
repository = RepositoryTestData.createHeartOfGold("hg");
when(context.getRepository()).thenReturn(repository);
when(context.create("README.md")).thenReturn(createFile);
}
@Test
void shouldCreateReadme() throws IOException {
initializer.initialize(context);
verify(createFile).from("# HeartOfGold\n\n" + repository.getDescription());
}
@Test
void shouldCreateReadmeWithoutDescription() throws IOException {
repository.setDescription(null);
initializer.initialize(context);
verify(createFile).from("# HeartOfGold");
}
}

View File

@@ -0,0 +1,181 @@
package sonia.scm.repository;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteSource;
import com.google.common.io.ByteStreams;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.Priority;
import sonia.scm.repository.api.ModifyCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RepositoryInitializerTest {
@Mock
private RepositoryServiceFactory repositoryServiceFactory;
@Mock
private RepositoryService repositoryService;
@Mock(answer = Answers.RETURNS_SELF)
private ModifyCommandBuilder modifyCommand;
private final Repository repository = RepositoryTestData.createHeartOfGold("git");
@BeforeEach
void setUpModifyCommand() {
when(repositoryServiceFactory.create(repository)).thenReturn(repositoryService);
when(repositoryService.getModifyCommand()).thenReturn(modifyCommand);
}
@Test
void shouldCallRepositoryContentInitializer() throws IOException {
ModifyCommandBuilder.WithOverwriteFlagContentLoader readmeContentLoader = mockContentLoader("README.md");
ModifyCommandBuilder.WithOverwriteFlagContentLoader licenseContentLoader = mockContentLoader("LICENSE.txt");
Set<RepositoryContentInitializer> repositoryContentInitializers = ImmutableSet.of(
new ReadmeContentInitializer(),
new LicenseContentInitializer()
);
RepositoryInitializer initializer = new RepositoryInitializer(repositoryServiceFactory, repositoryContentInitializers);
initializer.initialize(repository);
verifyFileCreation(readmeContentLoader, "# HeartOfGold");
verifyFileCreation(licenseContentLoader, "MIT");
verify(modifyCommand).setCommitMessage("initialize repository");
verify(modifyCommand).execute();
verify(repositoryService).close();
}
@Test
void shouldCallRepositoryContentInitializerWithInputStream() throws IOException {
ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader = mockContentLoader("awesome.txt");
Set<RepositoryContentInitializer> repositoryContentInitializers = ImmutableSet.of(
new StreamingContentInitializer()
);
RepositoryInitializer initializer = new RepositoryInitializer(repositoryServiceFactory, repositoryContentInitializers);
initializer.initialize(repository);
verifyFileCreationWithStream(contentLoader, "awesome");
verify(modifyCommand).setCommitMessage("initialize repository");
verify(modifyCommand).execute();
verify(repositoryService).close();
}
@Test
void shouldRespectPriorityOrder() throws IOException {
ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader = mock(ModifyCommandBuilder.WithOverwriteFlagContentLoader.class);
when(contentLoader.setOverwrite(true)).thenReturn(contentLoader);
when(modifyCommand.createFile(anyString())).thenReturn(contentLoader);
AtomicReference<String> reference = new AtomicReference<>();
when(contentLoader.withData(any(ByteSource.class))).thenAnswer(ic -> {
ByteSource byteSource = ic.getArgument(0);
reference.set(byteSource.asCharSource(StandardCharsets.UTF_8).read());
return modifyCommand;
});
Set<RepositoryContentInitializer> repositoryContentInitializers = ImmutableSet.of(
new LicenseContentInitializer(),
new ReadmeContentInitializer()
);
RepositoryInitializer initializer = new RepositoryInitializer(repositoryServiceFactory, repositoryContentInitializers);
initializer.initialize(repository);
assertThat(reference.get()).isEqualTo("MIT");
}
@Test
void shouldCloseRepositoryServiceOnException() throws IOException {
ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader = mockContentLoader("README.md");
doThrow(new IOException("epic fail")).when(contentLoader).withData(any(ByteSource.class));
RepositoryInitializer initializer = new RepositoryInitializer(repositoryServiceFactory, ImmutableSet.of(new ReadmeContentInitializer()));
assertThrows(InternalRepositoryException.class, () -> initializer.initialize(repository));
verify(repositoryService).close();
}
private ModifyCommandBuilder.WithOverwriteFlagContentLoader mockContentLoader(String path) {
ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader = mock(ModifyCommandBuilder.WithOverwriteFlagContentLoader.class);
doReturn(contentLoader).when(modifyCommand).createFile(path);
when(contentLoader.setOverwrite(true)).thenReturn(contentLoader);
return contentLoader;
}
private void verifyFileCreation(ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader, String expectedContent) throws IOException {
ArgumentCaptor<ByteSource> captor = ArgumentCaptor.forClass(ByteSource.class);
verify(contentLoader).withData(captor.capture());
String content = captor.getValue().asCharSource(StandardCharsets.UTF_8).read();
assertThat(content).isEqualTo(expectedContent);
}
private void verifyFileCreationWithStream(ModifyCommandBuilder.WithOverwriteFlagContentLoader contentLoader, String expectedContent) throws IOException {
ArgumentCaptor<InputStream> captor = ArgumentCaptor.forClass(InputStream.class);
verify(contentLoader).withData(captor.capture());
byte[] bytes = ByteStreams.toByteArray(captor.getValue());
assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo(expectedContent);
}
@Priority(1)
private static class ReadmeContentInitializer implements RepositoryContentInitializer {
@Override
public void initialize(InitializerContext context) throws IOException {
Repository repository = context.getRepository();
context.create("README.md").from("# " + repository.getName());
}
}
@Priority(2)
private static class LicenseContentInitializer implements RepositoryContentInitializer {
@Override
public void initialize(InitializerContext context) throws IOException {
context.create("LICENSE.txt").from("MIT");
}
}
private static class StreamingContentInitializer implements RepositoryContentInitializer {
@Override
public void initialize(InitializerContext context) throws IOException {
context.create("awesome.txt").from(new ByteArrayInputStream("awesome".getBytes(StandardCharsets.UTF_8)));
}
}
}