diff --git a/CHANGELOG.md b/CHANGELOG.md index 38cf1ee569..a83e5b743b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Set individual page title - Copy on write +- A new repository can be initialized with a branch (for git and mercurial) and custom files (README.md on default) ### Changed - Stop fetching commits when it takes too long diff --git a/scm-core/src/main/java/sonia/scm/Priorities.java b/scm-core/src/main/java/sonia/scm/Priorities.java index d2eb6062e0..d06395eace 100644 --- a/scm-core/src/main/java/sonia/scm/Priorities.java +++ b/scm-core/src/main/java/sonia/scm/Priorities.java @@ -71,12 +71,25 @@ public final class Priorities * * @return sorted class list */ - public static List> sort( - Iterable> unordered) + public static List> sort(Iterable> unordered) { return new PriorityOrdering().sortedCopy(unordered); } + /** + * Returns a list of instances sorted by priority. + * + * @param type of class + * @param unordered unordered instances + * + * @return sorted instance list + */ + public static List sortInstances(Iterable unordered) + { + return new PriorityInstanceOrdering().sortedCopy(unordered); + } + + //~--- get methods ---------------------------------------------------------- /** @@ -125,4 +138,28 @@ public final class Priorities return Ints.compare(getPriority(left), getPriority(right)); } } + + /** + * {@link Ordering} which orders instances by priority. + * + * @param type of instance + */ + public static class PriorityInstanceOrdering extends Ordering + { + + /** + * 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())); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryContentInitializer.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryContentInitializer.java new file mode 100644 index 0000000000..6c4274ee15 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryContentInitializer.java @@ -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; + + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java index 05c1babe03..85fb814df4 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java @@ -102,7 +102,7 @@ public class ModifyCommandBuilder { public String execute() { AuthorUtil.setAuthorIfNotAvailable(request); 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); } finally { try { diff --git a/scm-core/src/test/java/sonia/scm/PrioritiesTest.java b/scm-core/src/test/java/sonia/scm/PrioritiesTest.java index db284a28a3..578b2ac430 100644 --- a/scm-core/src/test/java/sonia/scm/PrioritiesTest.java +++ b/scm-core/src/test/java/sonia/scm/PrioritiesTest.java @@ -98,6 +98,21 @@ public class PrioritiesTest 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 -------------------------------------------------------- /** 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 d61a48508d..d89feb823b 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 @@ -70,6 +70,18 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase { } } + @Test + public void shouldCheckoutDefaultBranch() { + SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); + + try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext(), null)) { + assertThat(new File(workingCopy.getWorkingRepository().getWorkTree(), "a.txt")) + .exists() + .isFile() + .hasContent("a\nline for blame"); + } + } + @Test public void cloneFromPoolShouldNotBeReused() { SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 9539429609..2990311a84 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -21,7 +21,8 @@ "nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.", "typeHelpText": "Der Typ des Repository (Mercurial, Git oder Subversion).", "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": { "errorTitle": "Fehler", @@ -97,7 +98,8 @@ }, "repositoryForm": { "subtitle": "Repository bearbeiten", - "submit": "Speichern" + "submit": "Speichern", + "initializeRepository": "Repository initiieren" }, "sources": { "file-tree": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 0451003879..bab115934a 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -21,7 +21,8 @@ "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).", "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": { "errorTitle": "Error", @@ -97,7 +98,8 @@ }, "repositoryForm": { "subtitle": "Edit Repository", - "submit": "Save" + "submit": "Save", + "initializeRepository": "Initialize repository" }, "sources": { "file-tree": { diff --git a/scm-ui/ui-webapp/public/locales/es/repos.json b/scm-ui/ui-webapp/public/locales/es/repos.json index 6c8db3d60c..abd9d32575 100644 --- a/scm-ui/ui-webapp/public/locales/es/repos.json +++ b/scm-ui/ui-webapp/public/locales/es/repos.json @@ -21,7 +21,8 @@ "nameHelpText": "El nombre del repositorio. Este nombre formará parte de la URL del repositorio.", "typeHelpText": "El tipo del repositorio (Mercurial, Git or Subversion).", "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": { "errorTitle": "Error", @@ -97,7 +98,8 @@ }, "repositoryForm": { "subtitle": "Editar repositorio", - "submit": "Guardar" + "submit": "Guardar", + "initializeRepository": "Initialize repository" }, "sources": { "file-tree": { diff --git a/scm-ui/ui-webapp/src/repos/components/form/RepositoryForm.tsx b/scm-ui/ui-webapp/src/repos/components/form/RepositoryForm.tsx index 27e93961fe..191d48857d 100644 --- a/scm-ui/ui-webapp/src/repos/components/form/RepositoryForm.tsx +++ b/scm-ui/ui-webapp/src/repos/components/form/RepositoryForm.tsx @@ -1,12 +1,27 @@ import React from "react"; +import styled from "styled-components"; import { WithTranslation, withTranslation } from "react-i18next"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; 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"; +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 & { - submitForm: (p: Repository) => void; + submitForm: (repo: Repository, shouldInit: boolean) => void; repository?: Repository; repositoryTypes?: RepositoryType[]; namespaceStrategy?: string; @@ -15,6 +30,7 @@ type Props = WithTranslation & { type State = { repository: Repository; + initRepository: boolean; namespaceValidationError: boolean; nameValidationError: boolean; contactValidationError: boolean; @@ -35,6 +51,7 @@ class RepositoryForm extends React.Component { description: "", _links: {} }, + initRepository: false, namespaceValidationError: false, nameValidationError: false, contactValidationError: false @@ -71,7 +88,7 @@ class RepositoryForm extends React.Component { submit = (event: Event) => { event.preventDefault(); 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 { return !!this.props.repository && !!this.props.repository._links.update; }; + toggleInitCheckbox = () => { + this.setState({ + initRepository: !this.state.initRepository + }); + }; + render() { const { loading, t } = this.props; const repository = this.state.repository; @@ -175,13 +198,25 @@ class RepositoryForm extends React.Component { errorMessage={t("validation.name-invalid")} helpText={t("help.nameHelpText")} /> - + + + + + ); } diff --git a/scm-ui/ui-webapp/src/repos/containers/Create.tsx b/scm-ui/ui-webapp/src/repos/containers/Create.tsx index ff7aa11abf..a4ac4d7cf5 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Create.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Create.tsx @@ -31,7 +31,7 @@ type Props = WithTranslation & { // dispatch functions fetchNamespaceStrategiesIfNeeded: () => 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; // context props @@ -67,8 +67,8 @@ class Create extends React.Component { repositoryTypes={repositoryTypes} loading={createLoading} namespaceStrategy={namespaceStrategies.current} - submitForm={repo => { - createRepo(repoLink, repo, (repo: Repository) => this.repoCreated(repo)); + submitForm={(repo, initRepository) => { + createRepo(repoLink, repo, initRepository, (repo: Repository) => this.repoCreated(repo)); }} /> @@ -102,8 +102,8 @@ const mapDispatchToProps = (dispatch: any) => { fetchNamespaceStrategiesIfNeeded: () => { dispatch(fetchNamespaceStrategiesIfNeeded()); }, - createRepo: (link: string, repository: Repository, callback: () => void) => { - dispatch(createRepo(link, repository, callback)); + createRepo: (link: string, repository: Repository, initRepository: boolean, callback: () => void) => { + dispatch(createRepo(link, repository, initRepository, callback)); }, resetForm: () => { dispatch(createRepoReset()); diff --git a/scm-ui/ui-webapp/src/repos/modules/repos.test.ts b/scm-ui/ui-webapp/src/repos/modules/repos.test.ts index c5feb4b6e1..6666682f8f 100644 --- a/scm-ui/ui-webapp/src/repos/modules/repos.test.ts +++ b/scm-ui/ui-webapp/src/repos/modules/repos.test.ts @@ -430,7 +430,7 @@ describe("repos fetch", () => { }; const store = mockStore({}); - return store.dispatch(createRepo(URL, slartiFjords, callback)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords, false, callback)).then(() => { expect(callMe).toBe("yeah"); }); }); @@ -441,7 +441,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(createRepo(URL, slartiFjords)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords, false)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_REPO_PENDING); expect(actions[1].type).toEqual(CREATE_REPO_FAILURE); diff --git a/scm-ui/ui-webapp/src/repos/modules/repos.ts b/scm-ui/ui-webapp/src/repos/modules/repos.ts index c8560aa153..dbea9c24fd 100644 --- a/scm-ui/ui-webapp/src/repos/modules/repos.ts +++ b/scm-ui/ui-webapp/src/repos/modules/repos.ts @@ -155,11 +155,12 @@ export function fetchRepoFailure(namespace: string, name: string, error: Error): // 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) { dispatch(createRepoPending()); + const repoLink = initRepository ? link + "?initialize=true" : link; return apiClient - .post(link, repository, CONTENT_TYPE) + .post(repoLink, repository, CONTENT_TYPE) .then(response => { const location = response.headers.get("Location"); dispatch(createRepoSuccess()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java index 8b351aa46a..d27b598646 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java @@ -7,6 +7,7 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import org.apache.shiro.SecurityUtils; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryInitializer; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermission; import sonia.scm.search.SearchRequest; @@ -24,7 +25,7 @@ import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; - +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import static com.google.common.base.Strings.isNullOrEmpty; @@ -38,13 +39,15 @@ public class RepositoryCollectionResource { private final RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper; private final RepositoryDtoToRepositoryMapper dtoToRepositoryMapper; private final ResourceLinks resourceLinks; + private final RepositoryInitializer repositoryInitializer; @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.repositoryCollectionToDtoMapper = repositoryCollectionToDtoMapper; this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.resourceLinks = resourceLinks; + this.repositoryInitializer = repositoryInitializer; } /** @@ -68,10 +71,10 @@ public class RepositoryCollectionResource { @ResponseCode(code = 500, condition = "internal server error") }) public Response getAll(@DefaultValue("0") @QueryParam("page") int page, - @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, - @QueryParam("sortBy") String sortBy, - @DefaultValue("false") @QueryParam("desc") boolean desc, - @DefaultValue("") @QueryParam("q") String search + @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc, + @DefaultValue("") @QueryParam("q") String search ) { return adapter.getAll(page, pageSize, createSearchPredicate(search), sortBy, desc, pageResult -> repositoryCollectionToDtoMapper.map(page, pageSize, pageResult)); @@ -81,7 +84,7 @@ public class RepositoryCollectionResource { * Creates a new repository. * * Note: This method requires "repository" privilege. The namespace of the given repository will - * be ignored and set by the configured namespace strategy. + * be ignored and set by the configured namespace strategy. * * @param repository The repository to be created. * @return A response with the link to the new repository (if created successfully). @@ -98,10 +101,18 @@ public class RepositoryCollectionResource { }) @TypeHint(TypeHint.NO_CONTENT.class) @ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created repository")) - public Response create(@Valid RepositoryDto repository) { - return adapter.create(repository, + public Response create(@Valid RepositoryDto repository, @QueryParam("initialize") boolean initialize) { + AtomicReference reference = new AtomicReference<>(); + Response response = adapter.create(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) { diff --git a/scm-webapp/src/main/java/sonia/scm/repository/ReadmeRepositoryContentInitializer.java b/scm-webapp/src/main/java/sonia/scm/repository/ReadmeRepositoryContentInitializer.java new file mode 100644 index 0000000000..bc31763288 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/ReadmeRepositoryContentInitializer.java @@ -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); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/RepositoryInitializer.java b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryInitializer.java new file mode 100644 index 0000000000..fd1558b5d7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryInitializer.java @@ -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 contentInitializers; + + @Inject + public RepositoryInitializer(RepositoryServiceFactory serviceFactory, Set 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; + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index f4c49be693..fc0de8fe5c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -18,6 +18,7 @@ import org.mockito.Mock; import sonia.scm.PageResult; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryInitializer; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -76,6 +77,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { private ScmPathInfoStore scmPathInfoStore; @Mock private ScmPathInfo uriInfo; + @Mock + private RepositoryInitializer repositoryInitializer; @Captor private ArgumentCaptor> filterCaptor; @@ -95,7 +98,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { super.dtoToRepositoryMapper = dtoToRepositoryMapper; super.manager = repositoryManager; 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()); when(serviceFactory.create(any(Repository.class))).thenReturn(service); when(scmPathInfoStore.get()).thenReturn(uriInfo); @@ -288,6 +291,32 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { assertEquals(HttpServletResponse.SC_CREATED, response.getStatus()); assertEquals("/v2/repositories/otherspace/repo", response.getOutputHeaders().get("Location").get(0).toString()); 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 captor = ArgumentCaptor.forClass(Repository.class); + verify(repositoryInitializer).initialize(captor.capture()); + + Repository repository = captor.getValue(); + assertEquals("space", repository.getNamespace()); + assertEquals("repo", repository.getName()); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/repository/ReadmeRepositoryContentInitializerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/ReadmeRepositoryContentInitializerTest.java new file mode 100644 index 0000000000..49cd8dc9e1 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/ReadmeRepositoryContentInitializerTest.java @@ -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"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/RepositoryInitializerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryInitializerTest.java new file mode 100644 index 0000000000..7d5a511338 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryInitializerTest.java @@ -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 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 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 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 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 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 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))); + } + } + +}