From 5774d8a1ee5abe0b3597f7fceafaaef95fbbfdc6 Mon Sep 17 00:00:00 2001 From: Florian Scholdei Date: Wed, 17 Jul 2019 17:35:29 +0200 Subject: [PATCH 001/135] added inherit styling and pass className --- .../packages/ui-components/src/Notification.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/Notification.js b/scm-ui-components/packages/ui-components/src/Notification.js index b48ae16851..46b6b8edcf 100644 --- a/scm-ui-components/packages/ui-components/src/Notification.js +++ b/scm-ui-components/packages/ui-components/src/Notification.js @@ -2,11 +2,18 @@ import * as React from "react"; import classNames from "classnames"; -type NotificationType = "primary" | "info" | "success" | "warning" | "danger"; +type NotificationType = + | "primary" + | "info" + | "success" + | "warning" + | "danger" + | "inherit"; type Props = { type: NotificationType, onClose?: () => void, + className?: string, children?: React.Node }; @@ -24,9 +31,12 @@ class Notification extends React.Component { } render() { - const { type, children } = this.props; + const { type, className, children } = this.props; + + const color = type !== "inherit" ? "is-" + type : ""; + return ( -
+
{this.renderCloseButton()} {children}
From 474b9a99a0534a7ecbc8914157e79dfc71cb4c2a Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 24 Jul 2019 08:56:12 +0200 Subject: [PATCH 002/135] add bulma popover / add Extensionpoint --- .../src/repos/changesets/ChangesetButtonGroup.js | 2 +- .../src/repos/changesets/ChangesetRow.js | 13 +++++++++++-- .../packages/ui-types/src/Changesets.js | 4 ++-- scm-ui/package.json | 1 + scm-ui/src/repos/containers/RepositoryRoot.js | 2 +- scm-ui/styles/scm.scss | 7 +++++++ scm-ui/yarn.lock | 5 +++++ 7 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js index 72ce9a2b38..8d46a9f0b4 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js @@ -21,7 +21,7 @@ class ChangesetButtonGroup extends React.Component { const sourcesLink = createSourcesLink(repository, changeset); return ( - +
- +
+ +
+ +
+
diff --git a/scm-ui-components/packages/ui-types/src/Changesets.js b/scm-ui-components/packages/ui-types/src/Changesets.js index cab4233a7f..124fda3831 100644 --- a/scm-ui-components/packages/ui-types/src/Changesets.js +++ b/scm-ui-components/packages/ui-types/src/Changesets.js @@ -1,9 +1,9 @@ //@flow -import type {Links} from "./hal"; +import type {Collection, Links} from "./hal"; import type {Tag} from "./Tags"; import type {Branch} from "./Branches"; -export type Changeset = { +export type Changeset = Collection & { id: string, date: Date, author: { diff --git a/scm-ui/package.json b/scm-ui/package.json index dfb369e61e..a790935001 100644 --- a/scm-ui/package.json +++ b/scm-ui/package.json @@ -9,6 +9,7 @@ "@fortawesome/fontawesome-free": "^5.3.1", "@scm-manager/ui-extensions": "^0.1.2", "bulma": "^0.7.1", + "bulma-popover": "^1.0.0", "bulma-tooltip": "^2.0.2", "classnames": "^2.2.5", "font-awesome": "^4.7.0", diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index 6630086964..5cb2078e5a 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -125,7 +125,7 @@ class RepositoryRoot extends React.Component { return (
-
+
Date: Wed, 24 Jul 2019 13:55:53 +0200 Subject: [PATCH 003/135] Add method to embed collections --- .../java/sonia/scm/api/v2/resources/HalAppender.java | 10 ++++++++++ .../sonia/scm/api/v2/resources/EdisonHalAppender.java | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java index 6afb542646..b313f68af8 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java @@ -2,6 +2,8 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; +import java.util.List; + /** * The {@link HalAppender} can be used within an {@link HalEnricher} to append hateoas links to a json response. * @@ -34,6 +36,14 @@ public interface HalAppender { */ void appendEmbedded(String rel, HalRepresentation embeddedItem); + /** + * Appends a list of embedded objects to the json response. + * + * @param rel name of relation + * @param embeddedItems embedded objects + */ + void appendEmbedded(String rel, List embeddedItems); + /** * Builder for link arrays. */ diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java index 769de2b705..bb89c90556 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java @@ -33,6 +33,11 @@ class EdisonHalAppender implements HalAppender { embeddedBuilder.with(rel, embedded); } + @Override + public void appendEmbedded(String rel, List embedded) { + embeddedBuilder.with(rel, embedded); + } + private static class EdisonLinkArrayBuilder implements LinkArrayBuilder { private final Links.Builder builder; From 31013f8102fd8600a3682e154ea7a3a0d1e08d31 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Wed, 24 Jul 2019 16:08:16 +0000 Subject: [PATCH 004/135] Close branch feature/ci_integration From 1ea2bdfedff9ad540de50dc302aa2264d1b63fee Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 25 Jul 2019 11:34:16 +0200 Subject: [PATCH 005/135] change type to category --- scm-ui/src/admin/plugins/components/groupByCategory.js | 2 +- .../src/main/java/sonia/scm/api/v2/resources/PluginDto.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/admin/plugins/components/groupByCategory.js b/scm-ui/src/admin/plugins/components/groupByCategory.js index 1c542d45e3..49b6590d9a 100644 --- a/scm-ui/src/admin/plugins/components/groupByCategory.js +++ b/scm-ui/src/admin/plugins/components/groupByCategory.js @@ -6,7 +6,7 @@ export default function groupByCategory( ): PluginGroup[] { let groups = {}; for (let plugin of plugins) { - const groupName = plugin.type; + const groupName = plugin.category; let group = groups[groupName]; if (!group) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index d119eca711..a35c3e848d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -13,7 +13,7 @@ import lombok.Setter; public class PluginDto extends HalRepresentation { private String name; - private String type; + private String category; private String version; private String author; private String description; From 27dc47a590b33b2aa66ce1745586216e306acff6 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 26 Jul 2019 13:04:54 +0200 Subject: [PATCH 006/135] parse pluginBackendResponse to pluginCenterDto / add Endpoint / remove groupId + artefactId from plugins --- .../sonia/scm/config/ScmConfiguration.java | 2 +- .../sonia/scm/plugin/PluginInformation.java | 71 ++------------- .../plugin/PluginInformationComparator.java | 31 +++---- .../java/sonia/scm/plugin/SmpArchive.java | 10 +-- .../java/sonia/scm/plugin/SmpArchiveTest.java | 43 +++------ .../resources/PluginDtoCollectionMapper.java | 8 +- .../scm/api/v2/resources/PluginDtoMapper.java | 17 ++-- .../scm/api/v2/resources/PluginResource.java | 29 ++++++- .../scm/plugin/DefaultPluginManager.java | 57 +++++++----- .../java/sonia/scm/plugin/ExplodedSmp.java | 4 +- .../sonia/scm/plugin/PluginCenterDto.java | 87 +++++++++++++++++++ .../scm/plugin/PluginCenterDtoMapper.java | 5 ++ .../java/sonia/scm/plugin/PluginNode.java | 2 +- .../sonia/scm/plugin/PluginProcessor.java | 2 +- .../sonia/scm/plugin/PluginsInternal.java | 6 +- 15 files changed, 217 insertions(+), 157 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 8d3db8b348..6868182f10 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -73,7 +73,7 @@ public class ScmConfiguration implements Configuration { * Default plugin url */ public static final String DEFAULT_PLUGINURL = - "http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false"; + "http://download.scm-manager.org/api/v2/plugins.json"; /** * Default plugin url from version 1.0 diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 6de52c3cca..50d5c0d81f 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -88,7 +88,6 @@ public class PluginInformation { PluginInformation clone = new PluginInformation(); - clone.setArtifactId(artifactId); clone.setAuthor(author); clone.setCategory(category); clone.setTags(tags); @@ -99,7 +98,6 @@ public class PluginInformation } clone.setDescription(description); - clone.setGroupId(groupId); clone.setName(name); if (Util.isNotEmpty(screenshots)) @@ -139,13 +137,12 @@ public class PluginInformation final PluginInformation other = (PluginInformation) obj; //J- - return Objects.equal(artifactId, other.artifactId) - && Objects.equal(author, other.author) + return + Objects.equal(author, other.author) && Objects.equal(category, other.category) && Objects.equal(tags, other.tags) && Objects.equal(condition, other.condition) && Objects.equal(description, other.description) - && Objects.equal(groupId, other.groupId) && Objects.equal(name, other.name) && Objects.equal(screenshots, other.screenshots) && Objects.equal(state, other.state) @@ -164,8 +161,8 @@ public class PluginInformation @Override public int hashCode() { - return Objects.hashCode(artifactId, author, category, tags, condition, - description, groupId, name, screenshots, state, url, version, wiki); + return Objects.hashCode(author, category, tags, condition, + description, name, screenshots, state, url, version, wiki); } /** @@ -179,13 +176,11 @@ public class PluginInformation { //J- return MoreObjects.toStringHelper(this) - .add("artifactId", artifactId) .add("author", author) .add("category", category) .add("tags", tags) .add("condition", condition) .add("description", description) - .add("groupId", groupId) .add("name", name) .add("screenshots", screenshots) .add("state", state) @@ -198,17 +193,6 @@ public class PluginInformation //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @return - */ - public String getArtifactId() - { - return artifactId; - } - /** * Method description * @@ -253,16 +237,6 @@ public class PluginInformation return description; } - /** - * Method description - * - * - * @return - */ - public String getGroupId() - { - return groupId; - } /** * Method description @@ -273,7 +247,7 @@ public class PluginInformation @Override public String getId() { - return getId(true); + return getName(true); } /** @@ -285,11 +259,9 @@ public class PluginInformation * @return * @since 1.21 */ - public String getId(boolean withVersion) + public String getName(boolean withVersion) { - StringBuilder id = new StringBuilder(groupId); - - id.append(":").append(artifactId); + StringBuilder id = new StringBuilder(name); if (withVersion) { @@ -385,22 +357,11 @@ public class PluginInformation @Override public boolean isValid() { - return Util.isNotEmpty(groupId) && Util.isNotEmpty(artifactId) - && Util.isNotEmpty(name) && Util.isNotEmpty(version); + return Util.isNotEmpty(name) && Util.isNotEmpty(version); } //~--- set methods ---------------------------------------------------------- - /** - * Method description - * - * - * @param artifactId - */ - public void setArtifactId(String artifactId) - { - this.artifactId = artifactId; - } /** * Method description @@ -446,16 +407,6 @@ public class PluginInformation this.description = description; } - /** - * Method description - * - * - * @param groupId - */ - public void setGroupId(String groupId) - { - this.groupId = groupId; - } /** * Method description @@ -536,9 +487,6 @@ public class PluginInformation //~--- fields --------------------------------------------------------------- - /** Field description */ - private String artifactId; - /** Field description */ private String author; @@ -551,9 +499,6 @@ public class PluginInformation /** Field description */ private String description; - /** Field description */ - private String groupId; - /** Field description */ private String name; diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java index f44de35e8a..5443b2328d 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java @@ -75,29 +75,24 @@ public class PluginInformationComparator { int result = 0; - result = Util.compare(plugin.getGroupId(), other.getGroupId()); + result = Util.compare(plugin.getName(), other.getName()); if (result == 0) { - result = Util.compare(plugin.getArtifactId(), other.getArtifactId()); + PluginState state = plugin.getState(); + PluginState otherState = other.getState(); - if (result == 0) + if ((state != null) && (otherState != null)) { - PluginState state = plugin.getState(); - PluginState otherState = other.getState(); - - if ((state != null) && (otherState != null)) - { - result = state.getCompareValue() - otherState.getCompareValue(); - } - else if ((state == null) && (otherState != null)) - { - result = 1; - } - else if ((state != null) && (otherState == null)) - { - result = -1; - } + result = state.getCompareValue() - otherState.getCompareValue(); + } + else if ((state == null) && (otherState != null)) + { + result = 1; + } + else if ((state != null) && (otherState == null)) + { + result = -1; } } diff --git a/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java b/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java index 63d5e8fb8f..f674bdd2ba 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java +++ b/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java @@ -219,16 +219,10 @@ public final class SmpArchive throw new PluginException("could not find information section"); } - if (Strings.isNullOrEmpty(info.getGroupId())) + if (Strings.isNullOrEmpty(info.getName())) { throw new PluginException( - "could not find groupId in plugin descriptor"); - } - - if (Strings.isNullOrEmpty(info.getArtifactId())) - { - throw new PluginException( - "could not find artifactId in plugin descriptor"); + "could not find name in plugin descriptor"); } if (Strings.isNullOrEmpty(info.getVersion())) diff --git a/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java b/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java index 95addf388f..d7f4ecf515 100644 --- a/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java +++ b/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java @@ -85,7 +85,7 @@ public class SmpArchiveTest public void testExtract() throws IOException, ParserConfigurationException, SAXException { - File archive = createArchive("sonia.sample", "sample", "1.0"); + File archive = createArchive("sonia.sample", "1.0"); File target = tempFolder.newFolder(); IOUtil.mkdirs(target); @@ -112,7 +112,7 @@ public class SmpArchiveTest @Test public void testGetPlugin() throws IOException { - File archive = createArchive("sonia.sample", "sample", "1.0"); + File archive = createArchive("sonia.sample", "1.0"); Plugin plugin = SmpArchive.create(archive).getPlugin(); assertNotNull(plugin); @@ -121,8 +121,7 @@ public class SmpArchiveTest assertNotNull(info); - assertEquals("sonia.sample", info.getGroupId()); - assertEquals("sample", info.getArtifactId()); + assertEquals("sonia.sample", info.getName()); assertEquals("1.0", info.getVersion()); } @@ -132,22 +131,9 @@ public class SmpArchiveTest * @throws IOException */ @Test(expected = PluginException.class) - public void testWithMissingArtifactId() throws IOException + public void testWithMissingName() throws IOException { - File archive = createArchive("sonia.sample", null, "1.0"); - - SmpArchive.create(archive).getPlugin(); - } - - /** - * Method description - * - * @throws IOException - */ - @Test(expected = PluginException.class) - public void testWithMissingGroupId() throws IOException - { - File archive = createArchive(null, "sample", "1.0"); + File archive = createArchive( null, "1.0"); SmpArchive.create(archive).getPlugin(); } @@ -160,7 +146,7 @@ public class SmpArchiveTest @Test(expected = PluginException.class) public void testWithMissingVersion() throws IOException { - File archive = createArchive("sonia.sample", "sample", null); + File archive = createArchive("sonia.sample", null); SmpArchive.create(archive).getPlugin(); } @@ -169,13 +155,12 @@ public class SmpArchiveTest * Method description * * - * @param groupId - * @param artifactId + * @param name * @param version * * @return */ - private File createArchive(String groupId, String artifactId, String version) + private File createArchive(String name, String version) { File archiveFile; @@ -183,7 +168,7 @@ public class SmpArchiveTest { File descriptor = tempFolder.newFile(); - writeDescriptor(descriptor, groupId, artifactId, version); + writeDescriptor(descriptor, name, version); archiveFile = tempFolder.newFile(); try (ZipOutputStream zos = @@ -229,14 +214,13 @@ public class SmpArchiveTest * * * @param descriptor - * @param groupId - * @param artifactId + * @param name * @param version * * @throws IOException */ - private void writeDescriptor(File descriptor, String groupId, - String artifactId, String version) + private void writeDescriptor(File descriptor, String name, + String version) throws IOException { try @@ -252,8 +236,7 @@ public class SmpArchiveTest writer.writeStartDocument(); writer.writeStartElement("plugin"); writer.writeStartElement("information"); - writeElement(writer, "groupId", groupId); - writeElement(writer, "artifactId", artifactId); + writeElement(writer, "name", name); writeElement(writer, "version", version); writer.writeEndElement(); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index 72178e94f3..0bb5bd3610 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -4,6 +4,7 @@ import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; +import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginWrapper; import java.util.Collection; @@ -24,7 +25,12 @@ public class PluginDtoCollectionMapper { this.mapper = mapper; } - public HalRepresentation map(Collection plugins) { + public HalRepresentation map(List plugins) { + List dtos = plugins.stream().map(mapper::map).collect(toList()); + return new HalRepresentation(createLinks(), embedDtos(dtos)); + } + + public HalRepresentation map(Collection plugins) { List dtos = plugins.stream().map(mapper::map).collect(toList()); return new HalRepresentation(createLinks(), embedDtos(dtos)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index d17ecdae70..7b2108cd0e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -1,6 +1,7 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; +import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginWrapper; import javax.inject.Inject; @@ -16,16 +17,20 @@ public class PluginDtoMapper { } public PluginDto map(PluginWrapper plugin) { + return map(plugin.getPlugin().getInformation()); + } + + public PluginDto map(PluginInformation pluginInformation) { Links.Builder linksBuilder = linkingTo() .self(resourceLinks.plugin() - .self(plugin.getPlugin().getInformation().getId(false))); + .self(pluginInformation.getName())); PluginDto pluginDto = new PluginDto(linksBuilder.build()); - pluginDto.setName(plugin.getPlugin().getInformation().getName()); - pluginDto.setType(plugin.getPlugin().getInformation().getCategory() != null ? plugin.getPlugin().getInformation().getCategory() : "Miscellaneous"); - pluginDto.setVersion(plugin.getPlugin().getInformation().getVersion()); - pluginDto.setAuthor(plugin.getPlugin().getInformation().getAuthor()); - pluginDto.setDescription(plugin.getPlugin().getInformation().getDescription()); + pluginDto.setName(pluginInformation.getName()); + pluginDto.setCategory(pluginInformation.getCategory() != null ? pluginInformation.getCategory() : "Miscellaneous"); + pluginDto.setVersion(pluginInformation.getVersion()); + pluginDto.setAuthor(pluginInformation.getAuthor()); + pluginDto.setDescription(pluginInformation.getDescription()); return pluginDto; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java index c3b6ea6020..99c61191fa 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java @@ -4,7 +4,10 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.PluginCenter; +import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginLoader; +import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; import sonia.scm.plugin.PluginWrapper; import sonia.scm.web.VndMediaType; @@ -16,6 +19,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -27,12 +31,14 @@ public class PluginResource { private final PluginLoader pluginLoader; private final PluginDtoCollectionMapper collectionMapper; private final PluginDtoMapper mapper; + private final PluginManager pluginManager; @Inject - public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper) { + public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper, PluginCenter pluginCenter1, PluginManager pluginManager) { this.pluginLoader = pluginLoader; this.collectionMapper = collectionMapper; this.mapper = mapper; + this.pluginManager = pluginManager; } /** @@ -74,7 +80,7 @@ public class PluginResource { PluginPermissions.read().check(); Optional pluginDto = pluginLoader.getInstalledPlugins() .stream() - .filter(plugin -> id.equals(plugin.getPlugin().getInformation().getId(false))) + .filter(plugin -> id.equals(plugin.getPlugin().getInformation().getName(false))) .map(mapper::map) .findFirst(); if (pluginDto.isPresent()) { @@ -84,4 +90,23 @@ public class PluginResource { } } + /** + * Returns a collection of available plugins. + * + * @return collection of available plugins. + */ + @GET + @Path("/available") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(CollectionDto.class) + @Produces(VndMediaType.PLUGIN_COLLECTION) + public Response getAvailablePlugins() { + PluginPermissions.read().check(); + Collection plugins = pluginManager.getAvailable(); + return Response.ok(collectionMapper.map(plugins)).build(); + } + } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index ed1f691988..4bcfc3a06e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -67,10 +67,12 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -99,7 +101,7 @@ public class DefaultPluginManager implements PluginManager LoggerFactory.getLogger(DefaultPluginManager.class); /** enable or disable remote plugins */ - private static final boolean REMOTE_PLUGINS_ENABLED = false; + private static final boolean REMOTE_PLUGINS_ENABLED = true; /** Field description */ public static final Predicate FILTER_UPDATES = @@ -309,14 +311,12 @@ public class DefaultPluginManager implements PluginManager PluginPermissions.manage().check(); String[] idParts = id.split(":"); - String groupId = idParts[0]; - String artefactId = idParts[1]; + String name = idParts[0]; PluginInformation installed = null; for (PluginInformation info : getInstalled()) { - if (groupId.equals(info.getGroupId()) - && artefactId.equals(info.getArtifactId())) + if (name.equals(info.getName())) { installed = info; @@ -326,9 +326,9 @@ public class DefaultPluginManager implements PluginManager if (installed == null) { - StringBuilder msg = new StringBuilder(groupId); + StringBuilder msg = new StringBuilder(name); - msg.append(":").append(groupId).append(" is not install"); + msg.append(" is not install"); throw new PluginNotInstalledException(msg.toString()); } @@ -423,7 +423,7 @@ public class DefaultPluginManager implements PluginManager for (PluginInformation info : centerPlugins) { - if (!installedPlugins.containsKey(info.getId())) + if (!installedPlugins.containsKey(info.getName())) { availablePlugins.add(info); } @@ -590,7 +590,7 @@ public class DefaultPluginManager implements PluginManager */ private PluginCenter getPluginCenter() { - PluginCenter center = cache.get(PluginCenter.class.getName()); + PluginCenter center = null; // cache.get(PluginCenter.class.getName()); if (center == null) { @@ -605,15 +605,13 @@ public class DefaultPluginManager implements PluginManager logger.info("fetch plugin informations from {}", pluginUrl); } - /** - * remote plugins are disabled for early 2.0.0-SNAPSHOTS - * TODO enable remote plugins later - */ if (REMOTE_PLUGINS_ENABLED && Util.isNotEmpty(pluginUrl)) { try { - center = httpClient.get(pluginUrl).request().contentFromXml(PluginCenter.class); + center = new PluginCenter(); + PluginCenterDto pluginCenterDto = httpClient.get(pluginUrl).request().contentFromJson(PluginCenterDto.class); + center.setPlugins(mapPluginsFromPluginCenter(pluginCenterDto.getEmbedded().getPlugins())); preparePlugins(center); cache.put(PluginCenter.class.getName(), center); @@ -633,17 +631,35 @@ public class DefaultPluginManager implements PluginManager logger.error("could not load plugins from plugin center", ex); } } - - if (center == null) - { - center = new PluginCenter(); - } } } return center; } + private Set mapPluginsFromPluginCenter(List plugins) { + HashSet pluginInformationSet = new HashSet<>(); + + for (PluginCenterDto.Plugin plugin : plugins) { + + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName(plugin.getName()); + pluginInformation.setAuthor(plugin.getAuthor()); + pluginInformation.setCategory(plugin.getCategory()); + pluginInformation.setVersion(plugin.getVersion()); + pluginInformation.setDescription(plugin.getDescription()); + pluginInformation.setUrl(plugin.getLinks().getDownload()); + + if (plugin.getConditions() != null) { + PluginCenterDto.Condition condition = plugin.getConditions(); + pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), Collections.singletonList(condition.getOs()), condition.getArch())); + } + + pluginInformationSet.add(pluginInformation); + } + return pluginInformationSet; + } + /** * Method description * @@ -719,8 +735,7 @@ public class DefaultPluginManager implements PluginManager */ private boolean isSamePlugin(PluginInformation p1, PluginInformation p2) { - return p1.getGroupId().equals(p2.getGroupId()) - && p1.getArtifactId().equals(p2.getArtifactId()); + return p1.getName().equals(p2.getName()); } //~--- fields --------------------------------------------------------------- diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java index d1fe214f50..372470df14 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java @@ -115,8 +115,8 @@ public final class ExplodedSmp implements Comparable } else { - String id = plugin.getInformation().getId(false); - String oid = o.plugin.getInformation().getId(false); + String id = plugin.getInformation().getName(false); + String oid = o.plugin.getInformation().getName(false); if (depends.contains(oid) && odepends.contains(id)) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java new file mode 100644 index 0000000000..468249157a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -0,0 +1,87 @@ +package sonia.scm.plugin; + +import com.google.common.collect.ImmutableList; +import lombok.Getter; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.io.Serializable; +import java.util.List; + +@XmlRootElement +@XmlAccessorType(XmlAccessType.FIELD) +public final class PluginCenterDto implements Serializable { + + @XmlElement(name = "_embedded") + private Embedded embedded; + + public Embedded getEmbedded() { + return embedded; + } + + @XmlRootElement(name = "_embedded") + @XmlAccessorType(XmlAccessType.FIELD) + static class Embedded { + + @XmlElement(name = "plugins") + private List plugins; + + public List getPlugins() { + if (plugins == null) { + plugins = ImmutableList.of(); + } + return plugins; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "plugins") + @Getter + static class Plugin { + + private String name; + private String displayName; + private String description; + private String category; + private String version; + private String author; + private String sha256; + + @XmlElement(name = "conditions") + private Condition conditions; + + @XmlElement(name = "dependecies") + private Dependency dependencies; + + @XmlElement(name = "_links") + private Links links; + + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "conditions") + @Getter + static class Condition { + + private String os; + private String arch; + private String minVersion; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "dependencies") + @Getter + static class Dependency { + private String name; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "_links") + @Getter + static class Links { + private String download; + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java new file mode 100644 index 0000000000..77593fe56a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -0,0 +1,5 @@ +package sonia.scm.plugin; + +public class PluginCenterDtoMapper { + +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java index e28ccff2ff..8bc4f47658 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java @@ -126,7 +126,7 @@ public final class PluginNode */ public String getId() { - return plugin.getPlugin().getInformation().getId(false); + return plugin.getPlugin().getInformation().getName(false); } /** diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index c7d669ee63..e55254d1d1 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -377,7 +377,7 @@ public final class PluginProcessor URL[] urlArray = urls.toArray(new URL[urls.size()]); Plugin plugin = smp.getPlugin(); - String id = plugin.getInformation().getId(false); + String id = plugin.getInformation().getName(false); if (smp.getPlugin().isChildFirstClassLoader()) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java index 52d192da32..0354ded11a 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java @@ -109,7 +109,7 @@ public final class PluginsInternal { PluginInformation info = plugin.getInformation(); - return new File(new File(parent, info.getGroupId()), info.getArtifactId()); + return new File(parent, info.getName()); } /** @@ -131,14 +131,14 @@ public final class PluginsInternal if (directory.exists()) { logger.debug("delete directory {} for plugin extraction", - archive.getPlugin().getInformation().getId(false)); + archive.getPlugin().getInformation().getName(false)); IOUtil.delete(directory); } IOUtil.mkdirs(directory); logger.debug("extract plugin {}", - archive.getPlugin().getInformation().getId(false)); + archive.getPlugin().getInformation().getName(false)); archive.extract(directory); Files.write(checksum, checksumFile, Charsets.UTF_8); From bc37ccef572c2456677271039db2122f18a8991a Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Fri, 26 Jul 2019 15:07:40 +0200 Subject: [PATCH 007/135] Make protocol auth filter available for legacy paths --- ...otocolServletAuthenticationFilterBase.java | 12 ++-------- ...olServletAuthenticationFilterBaseTest.java | 6 ++--- ...cyProtocolServletAuthenticationFilter.java | 22 +++++++++++++++++++ ...tpProtocolServletAuthenticationFilter.java | 22 +++++++++++++++++++ 4 files changed, 49 insertions(+), 13 deletions(-) rename scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java => scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java (74%) rename scm-webapp/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterTest.java => scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java (91%) create mode 100644 scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyProtocolServletAuthenticationFilter.java create mode 100644 scm-webapp/src/main/java/sonia/scm/web/filter/DefaultHttpProtocolServletAuthenticationFilter.java diff --git a/scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java similarity index 74% rename from scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java rename to scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java index 973b3af4cb..8b1868309b 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java @@ -1,16 +1,11 @@ package sonia.scm.web.filter; -import sonia.scm.Priority; import sonia.scm.config.ScmConfiguration; -import sonia.scm.filter.Filters; -import sonia.scm.filter.WebElement; import sonia.scm.util.HttpUtil; import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgentParser; import sonia.scm.web.WebTokenGenerator; -import sonia.scm.web.protocol.HttpProtocolServlet; -import javax.inject.Inject; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; @@ -18,14 +13,11 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Set; -@Priority(Filters.PRIORITY_AUTHENTICATION) -@WebElement(value = HttpProtocolServlet.PATTERN) -public class HttpProtocolServletAuthenticationFilter extends AuthenticationFilter { +public class HttpProtocolServletAuthenticationFilterBase extends AuthenticationFilter { private final UserAgentParser userAgentParser; - @Inject - public HttpProtocolServletAuthenticationFilter( + protected HttpProtocolServletAuthenticationFilterBase( ScmConfiguration configuration, Set tokenGenerators, UserAgentParser userAgentParser) { diff --git a/scm-webapp/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterTest.java b/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java similarity index 91% rename from scm-webapp/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterTest.java rename to scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java index ff493e2b84..1f9b4fad07 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterTest.java +++ b/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java @@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class HttpProtocolServletAuthenticationFilterTest { +class HttpProtocolServletAuthenticationFilterBaseTest { private ScmConfiguration configuration = new ScmConfiguration(); @@ -32,7 +32,7 @@ class HttpProtocolServletAuthenticationFilterTest { @Mock private UserAgentParser userAgentParser; - private HttpProtocolServletAuthenticationFilter authenticationFilter; + private HttpProtocolServletAuthenticationFilterBase authenticationFilter; @Mock private HttpServletRequest request; @@ -48,7 +48,7 @@ class HttpProtocolServletAuthenticationFilterTest { @BeforeEach void setUpObjectUnderTest() { - authenticationFilter = new HttpProtocolServletAuthenticationFilter(configuration, tokenGenerators, userAgentParser); + authenticationFilter = new HttpProtocolServletAuthenticationFilterBase(configuration, tokenGenerators, userAgentParser); } @Test diff --git a/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyProtocolServletAuthenticationFilter.java b/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyProtocolServletAuthenticationFilter.java new file mode 100644 index 0000000000..ca6dea0898 --- /dev/null +++ b/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyProtocolServletAuthenticationFilter.java @@ -0,0 +1,22 @@ +package sonia.scm.legacy; + +import sonia.scm.Priority; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.filter.Filters; +import sonia.scm.filter.WebElement; +import sonia.scm.web.UserAgentParser; +import sonia.scm.web.WebTokenGenerator; +import sonia.scm.web.filter.HttpProtocolServletAuthenticationFilterBase; + +import javax.inject.Inject; +import java.util.Set; + +@Priority(Filters.PRIORITY_AUTHENTICATION) +@WebElement(value = "/git/*", morePatterns = {"/hg/*", "/svn/*"}) +public class LegacyProtocolServletAuthenticationFilter extends HttpProtocolServletAuthenticationFilterBase { + + @Inject + public LegacyProtocolServletAuthenticationFilter(ScmConfiguration configuration, Set tokenGenerators, UserAgentParser userAgentParser) { + super(configuration, tokenGenerators, userAgentParser); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/filter/DefaultHttpProtocolServletAuthenticationFilter.java b/scm-webapp/src/main/java/sonia/scm/web/filter/DefaultHttpProtocolServletAuthenticationFilter.java new file mode 100644 index 0000000000..f9b40961a3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/web/filter/DefaultHttpProtocolServletAuthenticationFilter.java @@ -0,0 +1,22 @@ +package sonia.scm.web.filter; + +import sonia.scm.Priority; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.filter.Filters; +import sonia.scm.filter.WebElement; +import sonia.scm.web.UserAgentParser; +import sonia.scm.web.WebTokenGenerator; +import sonia.scm.web.protocol.HttpProtocolServlet; + +import javax.inject.Inject; +import java.util.Set; + +@Priority(Filters.PRIORITY_AUTHENTICATION) +@WebElement(value = HttpProtocolServlet.PATTERN) +public class DefaultHttpProtocolServletAuthenticationFilter extends HttpProtocolServletAuthenticationFilterBase { + + @Inject + public DefaultHttpProtocolServletAuthenticationFilter(ScmConfiguration configuration, Set tokenGenerators, UserAgentParser userAgentParser) { + super(configuration, tokenGenerators, userAgentParser); + } +} From 64f3647acf820aeb2bdeb054b115a4c6b1179c04 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Fri, 26 Jul 2019 16:18:08 +0200 Subject: [PATCH 008/135] Fix unit test --- .../resources/mockito-extensions/org.mockito.plugins.MockMaker | 1 + 1 file changed, 1 insertion(+) create mode 100644 scm-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/scm-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/scm-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/scm-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline From 2fc0b56f63d3b0f7e61209638beb66ebea456a1d Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 29 Jul 2019 08:24:26 +0000 Subject: [PATCH 009/135] Close branch bugfix/legacy_checkout_for_other_realms From e0411ed17f0b29e402b8587a8483689d14c93b84 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 29 Jul 2019 11:22:57 +0200 Subject: [PATCH 010/135] create API for parsed diff result command --- .../api/AbstractDiffCommandBuilder.java | 68 ++++++++++++++++++ .../sonia/scm/repository/api/Command.java | 12 +--- .../repository/api/DiffCommandBuilder.java | 69 +++---------------- .../sonia/scm/repository/api/DiffFile.java | 12 ++++ .../sonia/scm/repository/api/DiffLine.java | 12 ++++ .../sonia/scm/repository/api/DiffResult.java | 8 +++ .../api/DiffResultCommandBuilder.java | 41 +++++++++++ .../java/sonia/scm/repository/api/Hunk.java | 12 ++++ .../scm/repository/api/RepositoryService.java | 15 ++++ .../scm/repository/spi/DiffResultCommand.java | 9 +++ .../spi/RepositoryServiceProvider.java | 5 ++ 11 files changed, 192 insertions(+), 71 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/AbstractDiffCommandBuilder.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/DiffLine.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/DiffResultCommandBuilder.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/Hunk.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/DiffResultCommand.java diff --git a/scm-core/src/main/java/sonia/scm/repository/api/AbstractDiffCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/AbstractDiffCommandBuilder.java new file mode 100644 index 0000000000..b5b2f2a08b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/AbstractDiffCommandBuilder.java @@ -0,0 +1,68 @@ +package sonia.scm.repository.api; + +import sonia.scm.FeatureNotSupportedException; +import sonia.scm.repository.Feature; +import sonia.scm.repository.spi.DiffCommandRequest; + +import java.util.Set; + +abstract class AbstractDiffCommandBuilder { + + + /** request for the diff command implementation */ + final DiffCommandRequest request = new DiffCommandRequest(); + + private final Set supportedFeatures; + + AbstractDiffCommandBuilder(Set supportedFeatures) { + this.supportedFeatures = supportedFeatures; + } + + /** + * Compute the incoming changes of the branch set with {@link #setRevision(String)} in respect to the changeset given + * here. In other words: What changes would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ + public T setAncestorChangeset(String revision) + { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name()); + } + request.setAncestorChangeset(revision); + + return self(); + } + + /** + * Show the difference only for the given path. + * + * + * @param path path for difference + * + * @return {@code this} + */ + public T setPath(String path) + { + request.setPath(path); + return self(); + } + + /** + * Show the difference only for the given revision or (using {@link #setAncestorChangeset(String)}) between this + * and another revision. + * + * + * @param revision revision for difference + * + * @return {@code this} + */ + public T setRevision(String revision) + { + request.setRevision(revision); + return self(); + } + + abstract T self(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index e380727769..3249e54ec3 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -53,11 +53,6 @@ public enum Command */ BRANCHES, - /** - * @since 2.0 - */ - BRANCH, - /** * @since 1.31 */ @@ -71,10 +66,5 @@ public enum Command /** * @since 2.0 */ - MODIFICATIONS, - - /** - * @since 2.0 - */ - MERGE + MODIFICATIONS, MERGE, DIFF_RESULT, BRANCH; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java index 9e7094d5bf..18d4e11a7f 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java @@ -38,10 +38,8 @@ package sonia.scm.repository.api; import com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.FeatureNotSupportedException; import sonia.scm.repository.Feature; import sonia.scm.repository.spi.DiffCommand; -import sonia.scm.repository.spi.DiffCommandRequest; import sonia.scm.util.IOUtil; import java.io.ByteArrayOutputStream; @@ -72,7 +70,7 @@ import java.util.Set; * @author Sebastian Sdorra * @since 1.17 */ -public final class DiffCommandBuilder +public final class DiffCommandBuilder extends AbstractDiffCommandBuilder { /** @@ -81,6 +79,9 @@ public final class DiffCommandBuilder private static final Logger logger = LoggerFactory.getLogger(DiffCommandBuilder.class); + /** implementation of the diff command */ + private final DiffCommand diffCommand; + //~--- constructors --------------------------------------------------------- /** @@ -92,8 +93,8 @@ public final class DiffCommandBuilder */ DiffCommandBuilder(DiffCommand diffCommand, Set supportedFeatures) { + super(supportedFeatures); this.diffCommand = diffCommand; - this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -162,54 +163,6 @@ public final class DiffCommandBuilder return this; } - - /** - * Show the difference only for the given path. - * - * - * @param path path for difference - * - * @return {@code this} - */ - public DiffCommandBuilder setPath(String path) - { - request.setPath(path); - - return this; - } - - /** - * Show the difference only for the given revision or (using {@link #setAncestorChangeset(String)}) between this - * and another revision. - * - * - * @param revision revision for difference - * - * @return {@code this} - */ - public DiffCommandBuilder setRevision(String revision) - { - request.setRevision(revision); - - return this; - } - /** - * Compute the incoming changes of the branch set with {@link #setRevision(String)} in respect to the changeset given - * here. In other words: What changes would be new to the ancestor changeset given here when the branch would - * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! - * - * @return {@code this} - */ - public DiffCommandBuilder setAncestorChangeset(String revision) - { - if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { - throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name()); - } - request.setAncestorChangeset(revision); - - return this; - } - //~--- get methods ---------------------------------------------------------- /** @@ -233,12 +186,8 @@ public final class DiffCommandBuilder diffCommand.getDiffResult(request, outputStream); } - //~--- fields --------------------------------------------------------------- - - /** implementation of the diff command */ - private final DiffCommand diffCommand; - private Set supportedFeatures; - - /** request for the diff command implementation */ - private final DiffCommandRequest request = new DiffCommandRequest(); + @Override + DiffCommandBuilder self() { + return this; + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java new file mode 100644 index 0000000000..d1d223e272 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java @@ -0,0 +1,12 @@ +package sonia.scm.repository.api; + +public interface DiffFile extends Iterable { + + String getOldRevision(); + + String getNewRevision(); + + String getOldName(); + + String getNewName(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffLine.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffLine.java new file mode 100644 index 0000000000..193e5e75d5 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffLine.java @@ -0,0 +1,12 @@ +package sonia.scm.repository.api; + +import java.util.OptionalInt; + +public interface DiffLine { + + OptionalInt getOldLineNumber(); + + OptionalInt getNewLineNumber(); + + String getContent(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java new file mode 100644 index 0000000000..b662db4e2d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java @@ -0,0 +1,8 @@ +package sonia.scm.repository.api; + +public interface DiffResult extends Iterable { + + String getOldRevision(); + + String getNewRevision(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffResultCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffResultCommandBuilder.java new file mode 100644 index 0000000000..7e152f3d0f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffResultCommandBuilder.java @@ -0,0 +1,41 @@ +package sonia.scm.repository.api; + +import com.google.common.base.Preconditions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.Feature; +import sonia.scm.repository.spi.DiffResultCommand; + +import java.io.IOException; +import java.util.Set; + +public class DiffResultCommandBuilder extends AbstractDiffCommandBuilder { + + private static final Logger LOG = LoggerFactory.getLogger(DiffResultCommandBuilder.class); + + private final DiffResultCommand diffResultCommand; + + DiffResultCommandBuilder(DiffResultCommand diffResultCommand, Set supportedFeatures) { + super(supportedFeatures); + this.diffResultCommand = diffResultCommand; + } + + /** + * Returns the content of the difference as parsed objects. + * + * @return content of the difference + */ + public DiffResult getDiffResult() throws IOException { + Preconditions.checkArgument(request.isValid(), + "path and/or revision is required"); + + LOG.debug("create diff result for {}", request); + + return diffResultCommand.getDiffResult(request); + } + + @Override + DiffResultCommandBuilder self() { + return this; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java b/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java new file mode 100644 index 0000000000..6e60f8b2bd --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java @@ -0,0 +1,12 @@ +package sonia.scm.repository.api; + +public interface Hunk extends Iterable { + + int getOldStart(); + + int getOldLineCount(); + + int getNewStart(); + + int getNewLineCount(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 90978d75ea..c06edcd918 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -239,6 +239,21 @@ public final class RepositoryService implements Closeable { return new DiffCommandBuilder(provider.getDiffCommand(), provider.getSupportedFeatures()); } + /** + * The diff command shows differences between revisions for a specified file + * or the entire revision. + * + * @return instance of {@link DiffResultCommandBuilder} + * @throws CommandNotSupportedException if the command is not supported + * by the implementation of the repository service provider. + */ + public DiffResultCommandBuilder getDiffResultCommand() { + LOG.debug("create diff result command for repository {}", + repository.getNamespaceAndName()); + + return new DiffResultCommandBuilder(provider.getDiffResultCommand(), provider.getSupportedFeatures()); + } + /** * The incoming command shows new {@link Changeset}s found in a different * repository location. diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/DiffResultCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/DiffResultCommand.java new file mode 100644 index 0000000000..ee50178d76 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/DiffResultCommand.java @@ -0,0 +1,9 @@ +package sonia.scm.repository.spi; + +import sonia.scm.repository.api.DiffResult; + +import java.io.IOException; + +public interface DiffResultCommand { + DiffResult getDiffResult(DiffCommandRequest request) throws IOException; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index a82eb7c30a..bf9cdf6a25 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -158,6 +158,11 @@ public abstract class RepositoryServiceProvider implements Closeable throw new CommandNotSupportedException(Command.DIFF); } + public DiffResultCommand getDiffResultCommand() + { + throw new CommandNotSupportedException(Command.DIFF_RESULT); + } + /** * Method description * From 785e5e114282b79c3404958d597d3f4091be0d04 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 29 Jul 2019 11:52:53 +0200 Subject: [PATCH 011/135] refactor plugin backend + fix tests --- .../sonia/scm/plugin/PluginInformation.java | 134 +++--------------- .../scm/plugin/DefaultPluginManager.java | 1 - .../sonia/scm/plugin/PluginProcessor.java | 8 +- .../api/v2/resources/ResourceLinksMock.java | 2 + .../sonia/scm/plugin/ExplodedSmpTest.java | 32 ++--- .../sonia/scm/plugin/PluginProcessorTest.java | 14 +- .../java/sonia/scm/plugin/PluginTreeTest.java | 28 ++-- .../sonia/scm/plugin/scm-b-plugin.smp | Bin 5787 -> 6872 bytes .../sonia/scm/plugin/scm-c-plugin.smp | Bin 4899 -> 5727 bytes .../sonia/scm/plugin/scm-d-plugin.smp | Bin 4899 -> 5727 bytes .../sonia/scm/plugin/scm-e-plugin.smp | Bin 4899 -> 5727 bytes .../sonia/scm/plugin/scm-f-plugin-1.0.0.smp | Bin 4822 -> 5654 bytes .../sonia/scm/plugin/scm-f-plugin-1.0.1.smp | Bin 4826 -> 5658 bytes 13 files changed, 54 insertions(+), 165 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 50d5c0d81f..663fd81f9f 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -44,11 +44,8 @@ import sonia.scm.util.Util; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlElementWrapper; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; -import java.util.ArrayList; import java.util.List; //~--- JDK imports ------------------------------------------------------------ @@ -87,29 +84,18 @@ public class PluginInformation public PluginInformation clone() { PluginInformation clone = new PluginInformation(); - + clone.setName(name); clone.setAuthor(author); clone.setCategory(category); - clone.setTags(tags); + clone.setDescription(description); + clone.setState(state); + clone.setVersion(version); if (condition != null) { clone.setCondition(condition.clone()); } - clone.setDescription(description); - clone.setName(name); - - if (Util.isNotEmpty(screenshots)) - { - clone.setScreenshots(new ArrayList(screenshots)); - } - - clone.setState(state); - clone.setUrl(url); - clone.setVersion(version); - clone.setWiki(wiki); - return clone; } @@ -140,15 +126,11 @@ public class PluginInformation return Objects.equal(author, other.author) && Objects.equal(category, other.category) - && Objects.equal(tags, other.tags) && Objects.equal(condition, other.condition) && Objects.equal(description, other.description) && Objects.equal(name, other.name) - && Objects.equal(screenshots, other.screenshots) - && Objects.equal(state, other.state) - && Objects.equal(url, other.url) - && Objects.equal(version, other.version) - && Objects.equal(wiki, other.wiki); + && Objects.equal(state, other.state) + && Objects.equal(version, other.version); //J+ } @@ -161,8 +143,8 @@ public class PluginInformation @Override public int hashCode() { - return Objects.hashCode(author, category, tags, condition, - description, name, screenshots, state, url, version, wiki); + return Objects.hashCode(author, category, condition, + description, name, state, version); } /** @@ -178,15 +160,11 @@ public class PluginInformation return MoreObjects.toStringHelper(this) .add("author", author) .add("category", category) - .add("tags", tags) .add("condition", condition) .add("description", description) .add("name", name) - .add("screenshots", screenshots) .add("state", state) - .add("url", url) .add("version", version) - .add("wiki", wiki) .toString(); //J+ } @@ -282,17 +260,6 @@ public class PluginInformation return name; } - /** - * Method description - * - * - * @return - */ - public List getScreenshots() - { - return screenshots; - } - /** * Method description * @@ -304,28 +271,6 @@ public class PluginInformation return state; } - /** - * Method description - * - * - * @return - */ - public List getTags() - { - return tags; - } - - /** - * Method description - * - * - * @return - */ - public String getUrl() - { - return url; - } - /** * Method description * @@ -343,9 +288,8 @@ public class PluginInformation * * @return */ - public String getWiki() - { - return wiki; + public List getLinks() { + return links; } /** @@ -362,7 +306,6 @@ public class PluginInformation //~--- set methods ---------------------------------------------------------- - /** * Method description * @@ -419,17 +362,6 @@ public class PluginInformation this.name = name; } - /** - * Method description - * - * - * @param screenshots - */ - public void setScreenshots(List screenshots) - { - this.screenshots = screenshots; - } - /** * Method description * @@ -441,28 +373,6 @@ public class PluginInformation this.state = state; } - /** - * Method description - * - * - * @param tags - */ - public void setTags(List tags) - { - this.tags = tags; - } - - /** - * Method description - * - * - * @param url - */ - public void setUrl(String url) - { - this.url = url; - } - /** * Method description * @@ -474,15 +384,15 @@ public class PluginInformation this.version = version; } + /** * Method description * * - * @param wiki + * @param links */ - public void setWiki(String wiki) - { - this.wiki = wiki; + public void setLinks(List links) { + this.links = links; } //~--- fields --------------------------------------------------------------- @@ -502,25 +412,13 @@ public class PluginInformation /** Field description */ private String name; - /** Field description */ - @XmlElement(name = "screenshot") - @XmlElementWrapper(name = "screenshots") - private List screenshots; - /** Field description */ private PluginState state; - /** Field description */ - @XmlElement(name = "tag") - @XmlElementWrapper(name = "tags") - private List tags; - - /** Field description */ - private String url; - /** Field description */ private String version; /** Field description */ - private String wiki; + private List links; + } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 4bcfc3a06e..a62916ed41 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -648,7 +648,6 @@ public class DefaultPluginManager implements PluginManager pluginInformation.setCategory(plugin.getCategory()); pluginInformation.setVersion(plugin.getVersion()); pluginInformation.setDescription(plugin.getDescription()); - pluginInformation.setUrl(plugin.getLinks().getDownload()); if (plugin.getConditions() != null) { PluginCenterDto.Condition condition = plugin.getConditions(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index e55254d1d1..b91ee9b1ee 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -318,10 +318,7 @@ public final class PluginProcessor { for (Path parent : parentStream) { - try (DirectoryStream direcotries = stream(parent, filter)) - { - paths.addAll(direcotries); - } + paths.add(parent); } } @@ -333,7 +330,6 @@ public final class PluginProcessor * * * @param parentClassLoader - * @param directory * @param smp * * @return @@ -472,7 +468,6 @@ public final class PluginProcessor * * * @param classLoader - * @param directory * @param smp * * @return @@ -511,7 +506,6 @@ public final class PluginProcessor * * * @param classLoader - * @param smps * @param rootNodes * * @return diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 6950d882f4..539b5c8d99 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -36,6 +36,8 @@ public class ResourceLinksMock { when(resourceLinks.modifications()).thenReturn(new ResourceLinks.ModificationsLinks(uriInfo)); when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo)); when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo)); + when(resourceLinks.pluginCollection()).thenReturn(new ResourceLinks.PluginCollectionLinks(uriInfo)); + when(resourceLinks.plugin()).thenReturn(new ResourceLinks.PluginLinks(uriInfo)); when(resourceLinks.uiPluginCollection()).thenReturn(new ResourceLinks.UIPluginCollectionLinks(uriInfo)); when(resourceLinks.uiPlugin()).thenReturn(new ResourceLinks.UIPluginLinks(uriInfo)); when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(uriInfo)); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java index b7bde65677..601725d938 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java @@ -60,12 +60,12 @@ public class ExplodedSmpTest @Test public void testCompareTo() { - ExplodedSmp e1 = create("a", "c", "1", "a:a"); + ExplodedSmp e1 = create("a", "c", "1", "a"); ExplodedSmp e3 = create("a", "a", "1"); ExplodedSmp e2 = create("a", "b", "1"); List es = list(e1, e2, e3); - is(es, 2, "c"); + is(es, 2, "a"); } /** @@ -75,9 +75,9 @@ public class ExplodedSmpTest @Test(expected = PluginCircularDependencyException.class) public void testCompareToCyclicDependency() { - ExplodedSmp e1 = create("a", "a", "1", "a:c"); - ExplodedSmp e2 = create("a", "b", "1"); - ExplodedSmp e3 = create("a", "c", "1", "a:a"); + ExplodedSmp e1 = create("a", "1", "c"); + ExplodedSmp e2 = create("b", "1"); + ExplodedSmp e3 = create("c", "1", "a"); list(e1, e2, e3); } @@ -89,9 +89,9 @@ public class ExplodedSmpTest @Test public void testCompareToTransitiveDependencies() { - ExplodedSmp e1 = create("a", "a", "1", "a:b"); - ExplodedSmp e2 = create("a", "b", "1"); - ExplodedSmp e3 = create("a", "c", "1", "a:a"); + ExplodedSmp e1 = create("a", "1", "b"); + ExplodedSmp e2 = create("b", "1"); + ExplodedSmp e3 = create("c", "1", "a"); List es = list(e1, e2, e3); @@ -107,9 +107,9 @@ public class ExplodedSmpTest @Test public void testMultipleDependencies() { - ExplodedSmp e1 = create("a", "a", "1", "a:b", "a:c"); - ExplodedSmp e2 = create("a", "b", "1", "a:c"); - ExplodedSmp e3 = create("a", "c", "1"); + ExplodedSmp e1 = create("a", "1", "b", "c"); + ExplodedSmp e2 = create("b", "1", "c"); + ExplodedSmp e3 = create("c", "1"); List es = list(e1, e2, e3); is(es, 2, "a"); @@ -119,20 +119,18 @@ public class ExplodedSmpTest * Method description * * - * @param groupId - * @param artifactId + * @param name * @param version * @param dependencies * * @return */ - private ExplodedSmp create(String groupId, String artifactId, String version, + private ExplodedSmp create(String name, String version, String... dependencies) { PluginInformation info = new PluginInformation(); - info.setGroupId(groupId); - info.setArtifactId(artifactId); + info.setName(name); info.setVersion(version); Plugin plugin = new Plugin(2, info, null, null, false, @@ -170,6 +168,6 @@ public class ExplodedSmpTest */ private void is(List es, int p, String a) { - assertEquals(a, es.get(p).getPlugin().getInformation().getArtifactId()); + assertEquals(a, es.get(p).getPlugin().getInformation().getName()); } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java index 8b352b8e68..87e9cbf7b7 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java @@ -71,37 +71,37 @@ public class PluginProcessorTest /** Field description */ private static final PluginResource PLUGIN_A = new PluginResource("sonia/scm/plugin/scm-a-plugin.smp", "scm-a-plugin.smp", - "sonia.scm.plugins:scm-a-plugin:1.0.0-SNAPSHOT"); + "scm-a-plugin:1.0.0-SNAPSHOT"); /** Field description */ private static final PluginResource PLUGIN_B = new PluginResource("sonia/scm/plugin/scm-b-plugin.smp", "scm-b-plugin.smp", - "sonia.scm.plugins:scm-b-plugin:1.0.0-SNAPSHOT"); + "scm-b-plugin:1.0.0-SNAPSHOT"); /** Field description */ private static final PluginResource PLUGIN_C = new PluginResource("sonia/scm/plugin/scm-c-plugin.smp", "scm-c-plugin.smp", - "sonia.scm.plugins:scm-c-plugin:1.0.0-SNAPSHOT"); + "scm-c-plugin:1.0.0-SNAPSHOT"); /** Field description */ private static final PluginResource PLUGIN_D = new PluginResource("sonia/scm/plugin/scm-d-plugin.smp", "scm-d-plugin.smp", - "sonia.scm.plugins:scm-d-plugin:1.0.0-SNAPSHOT"); + "scm-d-plugin:1.0.0-SNAPSHOT"); /** Field description */ private static final PluginResource PLUGIN_E = new PluginResource("sonia/scm/plugin/scm-e-plugin.smp", "scm-e-plugin.smp", - "sonia.scm.plugins:scm-e-plugin:1.0.0-SNAPSHOT"); + "scm-e-plugin:1.0.0-SNAPSHOT"); /** Field description */ private static final PluginResource PLUGIN_F_1_0_0 = new PluginResource("sonia/scm/plugin/scm-f-plugin-1.0.0.smp", - "scm-f-plugin.smp", "sonia.scm.plugins:scm-f-plugin:1.0.0"); + "scm-f-plugin.smp", "scm-f-plugin:1.0.0"); /** Field description */ private static final PluginResource PLUGIN_F_1_0_1 = new PluginResource("sonia/scm/plugin/scm-f-plugin-1.0.1.smp", - "scm-f-plugin.smp", "sonia.scm.plugins:scm-f-plugin:1.0.1"); + "scm-f-plugin.smp", "scm-f-plugin:1.0.1"); //~--- methods -------------------------------------------------------------- diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java index 06d6c1732c..0115f4510e 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java @@ -71,7 +71,7 @@ public class PluginTreeTest { PluginCondition condition = new PluginCondition("999", new ArrayList(), "hit"); - Plugin plugin = new Plugin(2, createInfo("a", "b", "1"), null, condition, + Plugin plugin = new Plugin(2, createInfo("a", "1"), null, condition, false, null); ExplodedSmp smp = createSmp(plugin); @@ -102,7 +102,7 @@ public class PluginTreeTest List smps = createSmps("a", "b", "c"); List nodes = unwrapIds(new PluginTree(smps).getRootNodes()); - assertThat(nodes, containsInAnyOrder("a:a", "b:b", "c:c")); + assertThat(nodes, containsInAnyOrder("a", "b", "c")); } /** @@ -114,7 +114,7 @@ public class PluginTreeTest @Test(expected = PluginException.class) public void testScmVersion() throws IOException { - Plugin plugin = new Plugin(1, createInfo("a", "b", "1"), null, null, false, + Plugin plugin = new Plugin(1, createInfo("a", "1"), null, null, false, null); ExplodedSmp smp = createSmp(plugin); @@ -141,34 +141,32 @@ public class PluginTreeTest PluginTree tree = new PluginTree(smps); List rootNodes = tree.getRootNodes(); - assertThat(unwrapIds(rootNodes), containsInAnyOrder("a:a")); + assertThat(unwrapIds(rootNodes), containsInAnyOrder("a")); PluginNode a = rootNodes.get(0); - assertThat(unwrapIds(a.getChildren()), containsInAnyOrder("b:b", "c:c")); + assertThat(unwrapIds(a.getChildren()), containsInAnyOrder("b", "c")); - PluginNode b = a.getChild("b:b"); + PluginNode b = a.getChild("b"); - assertThat(unwrapIds(b.getChildren()), containsInAnyOrder("c:c")); + assertThat(unwrapIds(b.getChildren()), containsInAnyOrder("c")); } /** * Method description * * - * @param groupId - * @param artifactId + * @param name * @param version * * @return */ - private PluginInformation createInfo(String groupId, String artifactId, + private PluginInformation createInfo(String name, String version) { PluginInformation info = new PluginInformation(); - info.setGroupId(groupId); - info.setArtifactId(artifactId); + info.setName(name); info.setVersion(version); return info; @@ -201,7 +199,7 @@ public class PluginTreeTest */ private ExplodedSmp createSmp(String name) throws IOException { - return createSmp(new Plugin(2, createInfo(name, name, "1.0.0"), null, null, + return createSmp(new Plugin(2, createInfo(name, "1.0.0"), null, null, false, null)); } @@ -224,10 +222,10 @@ public class PluginTreeTest for (String d : dependencies) { - dependencySet.add(d.concat(":").concat(d)); + dependencySet.add(d); } - Plugin plugin = new Plugin(2, createInfo(name, name, "1"), null, null, + Plugin plugin = new Plugin(2, createInfo(name, "1"), null, null, false, dependencySet); return createSmp(plugin); diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-b-plugin.smp b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-b-plugin.smp index 4106b9794585e53f4ef50d9ab63eee75b9657e56..a70205e3eb05275a8846e3fbffdce57aa9b73b4c 100644 GIT binary patch delta 3361 zcmbQOd&879z?+#xWU{`HxV{2Ia!z7#acZ%CXb3L@yTsq7kgPp+u^?Pp!Og(P@`9Ox z0ZhR3h*}9UAawbV&~?$O!+L64TMC@`H<`^&dlP9_$#92_THcgsowL`Hw6F>dvp9@izEAg7JcB z)}PakFPv|1!9DKg$3qc`?EW_oX6+Wdd`t9E(r4Aw1D7iQ$)vFq>^*l$-AdVbMqd2| z4+XF5vwQTWS?XM_a5G{{G&+1&<@}2+CxY)twD2gi-_345W^j|?=e~0s$rI8X!e7r& zQb;>-NM(5oZ=+gZ%$bZ$5=>1Flb#7&7Cn1W*T3Ctqw{Z5kxQ0qJj8TA8qT>i^rckMg-s{)k8Mu%Rmu{7Vtp{_P>%7NLdl<- z9n?(@>BO{hACkE9I@o(nTcsC)r$lZ`S}d|W(KcK zNa>jQ>4IRrgT#~`+Z$iEBwXoqpFAfea^tzoS$7il&kvM*B9-*w%*r`xwUh7WEV+4% zDcUzix$VOql^ydVW9JqvoZ7swDX{Ksn`PjIKf!w^OmOuH{M8ZBe?`eQ*e!2E`IJcZ zmaQx2JD-W(@hNxrgxKv%<~BXgl-qTTX|A(H^`dPpb_~xOFV@wU&1t!~Dc|vb&>o56 zKd*jACvIE*irqYU<+puh<<09?r(G#|zvFr3;)^O4a(>kp{S1WmH*Wp@{qhsL59{T1 zD-@!t|Lttfcrv68iD?(Ay+7>22YkN|tz^9&F|NO_d9jXs!>0Y+^P=AT%&BU*q2XiYAddjN(50u^h z&hwZ2L7gt~D>30-GINFIvaZ|4^#9g7)_?2|*QiXlULd|n@U81F|D&4V zer3*_zwy`c`b)oaiVUV3oDVBGa#{MnykObmPe0xr(W$>ypZa0lyj>dqZT#z*14O*p zIX=C+9T~>LzyKDUMyu5OtUy6uxt}hQU#Z7M5q^K z)&rG>0{XT@=>V6fO!;lDu%h_~8v}zW*aTzcirgHqAy57^g@EL-ICFC)UoI1SHBiz9 ztjU&OO*C0cu$vLwsIXT6843n(9YGZFO$o@_k`BUW7?Y8edGVW>QiIhbBlH$O-~30X0Qn;H~32 zRz&a++c1Iq${W-)fq}PH&f>*&QcS&~Tx5%IX%Ejl7{d;p_uW1bEvy;E02_bdZY_U`q#PG{cfc1Fp$$I3yu~ho{*C zi#cH6d4NhH7Y5ZyiVaR!Wcm~#_}ii#hIMnw-T1hI%Bhnm91N<&B?dP#90wrMQ1k}b6n z(p%&wXk#I0BWk5TK(Ms&V`eY6x3O?S2$_8|Z|2Q=vd<@@1*a{N;Ac7;BV;|lwLH1D zu`-3p6w%?6tsduz5-CFXhi>uV_8XeuKT*rn;k9&7^t`es>O{83RI)8?#WS^drN^a}~|L^sL#!A@i00df8ctRoc<%;if3_G;1M zyaU~)_A+t-mpID{0;XFQLjPTFU1+PY%qKX`mIbK5ND|%17kc_aQ6YWLUo$p(vb*3! Pg-uOjl1C8nO4#NXYH!Zn diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-c-plugin.smp b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-c-plugin.smp index aee452fac4a5056e2439f140faa9f361407791c5..b80169b9b594a9560803fbdde39124e020dd4834 100644 GIT binary patch delta 2953 zcmZ3ic3+1zz?+#xWO5?Es38Y~0z-06VsUY5v3_U>F9W;G-=>fWd+cICxU_E}kAb_Ni567|;)%zzT2*o-Sq&V?BHcNfGp2p6fLI*EY; z2$9Vc;$&cuXHb~@l2M@^>?x2DFk{d>wKXg-|B;12-ShA>AGb_x*s^!}WwTV#CPr_? zhmV%T$~h;k*s9{TEpz+-f5-eHG-6-Adn&M~Fa6o<{CzWTeBJ+d&G!$driyKQ%Ua2)>!;Zuiq3+)zXjz1;6 zWB=aWvJ8)BJ4pwc9MD=B@=&JvuiTu)Y)4JQ*YAb5WH zvgaf>slZQ-&WHOe1*N`mWEARMHoGdSekST2cVMw;(ZTQYS0t1%F@N2`D^KREU6TfJhUdk6-dTGWlS+hIm1jLjI=M-`j&NGl%dF4_;M(82W zH4~KilyWXUV6kM(+G(0!CbNr^x%Ra*DRAYTpO#XXBHj6rZY43yrUPx z_0=Yoi-L_>PfFGIvK&g;>i4o{;z6a(Wh$uxM}qJ4Zl2=zN#|g1-2sD5_xG7)@H{e| z@P73a12#{tlAT=|Oi67f9mZZa7&2tseoi^|nNfk`h$ow1>?NMNn^F>lt+$+CIN?l! z=cR(i{(~)UCegd+7|%LtcArO>Z3EY(p0-MnB^yp%4n4$?P{~*1RUawATH?7(`AEN^ z=>&D5^aEm!B0DDq8y)98{?~b~aFa^2r^_v!kGq(CJZIdTbZ)hPs7syTJY8*}pxp}h zImLvRymo9qed4~Q)k48#2aRS3KYOt6Pq)d9`3C+cF9o^jM%X1vp8eEwz{Yb~pNaBI zVP4Maf`-6DbFwUzna@mN?PI7vv>@fp!FB4Ei~Ve*(p7vvhq_Jv!S?%a&nboL49?5_ zo~pc?^(IZ(^SaoLtVdFY4bNE*{=M?*)fDbEGCZO@o4w7?bT*}he|7SCCg}RoImuS2 z`-_j{&V%;?(_{s%Evw@*$bWZg`&*~7x0AMU&0ALaK#IGB?ckTrTSB`S=CHf%zFA)= z)YiR@^{)5Fq%#j!KhJHsDRWx>PS28CmACHl&eMCHcz569y>a;#GQZpZ7OKxObbEAt z`?q&>Qulw~d%BqQyKC}!`*MNaSfg_DovwvP%ENnW*GKP&J(F;_%u9}ci&XZ_j--|q zi_O-4+dFyXbMaFhxq(uz+bd5ezHV+vcRp`_vHsxc;u7nW!VO8?Dxdepo77gP#47Gs zJFh<7=%a-E%kSzVumpowq8VU*|O@@#uZ!neTtzV}H0t<+=3&;Z0&^BY&-a zq?c=UV|ULs>Gnl`8n^gI+|l!i&z-{)y3t7{RrGA1|^Q@Pe06RRAt`k)2}( z^qn}c+;tF?P=H%M5n6nsmAji)vlX&HtheAtwmvtpEH#g~$^=w{F)IQsT*$4Y3bv4t zlfgRlz&i9HPQ;^Q@&#@&_MdDF45knb>IM0^dKI}jV4Lp!!5Olfm3eZR*sFoTZUfOc z`M(e!!ZsZ7G5HE#4)saW66Xr;W@j<{Q0X!+eDqk$=Fu)sP2dolD&MLr49A*W>lExR4pED|{ zgWZg$LWi0MjA?O8gzmSF>sT2O`Gk;d{TX+_((r3}{A%J7`JcH-4z0f|F12ahbsr b6G#abM+JDZ0t*oa23{Zx2BxEL0w5j$i0jsj delta 1114 zcmcbwvsleJz?+$civa`_^4na&3@E_~qM@LP!>&@2*c!mLSmEM z*^V%BP5vt&UeCagoRe5woLUUlgxzdGpxH20D0X01E{aWgadIv;8;qgO=S6ltOf7aV z$WOKuQrf(ny_jWk1iJt`$N+H$hRONt5;h(7ZYx zC0X3Ttp`yt`9BXYSQW&nfAdfy#T226y&ykVuOc^R^L*Z1Ca|Mz5R#Ln#rP%{2=v5a zcRMI{yg?dZaSu@e zGF1|&0T#zlHDF2fbOuTb5CGC;kE9FDP1wBxN*gfkVQ(Gtu<3_HKiHXIrRYuor4F9W;W-=>gld+cICxU_v1S&!8~* zC8L4@+y@{zEI!!0nyrw9QDkx=zj(a@1Ke`3HU$D6$FNv{k%2)3=oh##zK(vLZmz*0 zdcJOi9Fp2UG5S!72-pj6qyG9q{Q{_?eGerZ#NZJN>d*s%R6Vx8lP`OJe1mlU8h1aod); z{r|sXei0h6FW)^CSk#yPYnK!=f|GVbY3K5+4Q?d#$af3~#V;KyOXjQ!K|BUGns zTG_&TNpHooXW6G;=asnnHs6_NvC!p3^%b_&hO9+f7aVol7j@ZllABcEr$*<){gr}J z-#9V~^)8!T6;(eI^^QBR*tF>2_xURmPI3GCg>1O+s^iN)b+ZeiQ71n|d_HEe(Z50B zMWVlomBtG0!&5cB{aUj(dscn-K8+=7-=7ye60^hM^#u{r?Q0@-$mn%1yHWVH;mdiq z>zs*SG)*t%3ogAhU&uZ zrEK+kSu^pVQs*+2RDmPGcX~HZ@%yB6u($4j!KVBB%rbZ$nNE1WdWr#?Cs)bNE)Axn zHj@rxuNw>*GHySo9Q(|uz;VQrO)&Nn&)rQa3BuM}&M%yBCc*PkL1X{H7B`dV-E)j* z9W}epBh0pe>rzi!rO1*Er!I#c;z+3EEApz3lwd9KT&8@a-_Ue|x={K7F-MV|lY)(o z^B(`}JXg3$rP*CwouS+h5MXh!b@H|wx2$6-_mNK z;Ie~8GlZW#*!QQ~mh|C5)3+;k)C5+%=m>N#NJxvbAb`K2%~XLUhC;GsELmdea$ zrm*%g)E`=q^5)<=b<4$mHd5&-zMn(gCjVgj{kP|o!gU7c<$h09-pzWGrtEoL>_*lj zDZ_^6tOx&IdG%@v_Zk@`+zwbR=%=+Cm z`MiC(KyR#3x%p1l!XxG3y|wG3cf_7aI9%o>$G=4?`({T{%ZkNjYrpNCyz;sDsgB%0 zsn_k5s}o;0H>5kCx4&3_@N{vBbxPreq;8eZd*e-Nt5aeXcdVUPAM*QSeaP{()|2kJ z8+2d#ti8@#mCvv98k2bRzVgiXKkubdV%mJv9poCRzK3qHM_C9XPb2UqCbsW z{3Gr@e*eX8rpy`3HR;+`4_@~E@8{TESXWb?^eO&pyo6ol)cxN-t=q8o@)hyF%mLo) z9Io@W`$n=bFn~%XNL`Z1FKWS$T-@a*mZj!FDgr{~F|ZI9WmW_ha0|JWREa9V!8-K7 zI`kn`1s*#lU*HyF|H;O{U<%QoUXY)wSCN|ow&~WNrVvnBjioT&tjv?k#9j@Qw1H@x z{9lNVV8t}~3SSSfUINz0j-VPD2HrY?Sj5-Qj7%cTAXCIW6ktX$ymfR#G6P;&f&GEE zvWFQa>JF;0Vc@OfRBXn<>nS`&PCh0e$BQs&N#l#j&l#1};VuDLfh9x(ydf?DR@#;b z-ESQ?u`(d)UP1|A@n5S*hPno95OQ@35&+fQF!0uqn;pr0pz!el zI}%m}y4%UR^&3WEa26P5Ax56E?r12X+)KJ06r}((c2q#fiU`YW=0K5#rz=ZWp H0K@|T@UPCW delta 1128 zcmcbwvsleJz?+$civa|b^4na&3@E_~qM@LP!Ua;2*c!mLSmD> z*^V%BP5vt&UeCagoRe5woLUUl1Th<3lOWJ+m?{)IuqzkEro1>g7n=>nQ0Ma^J0GSN zyBFjqTM8*{c4aJPk-+9daiB?)`5484DhgTLum=`DvTeDEWvO{cZbJ`QMWA_gJW8^- z!(I=fV)B0;Ua%^NyMO1Q#*isO6?;K`u3klM&gS{NxlHV!Ah1D5PL>wqn_M8!6N}yL zpooS50fx7ZAQn=TFfxfSBSP)|q3LHf02K%@GBAih^nl4Fjq2cJz#iZY(f~^c5EUR( zC4m}X5e`)Ymc*7Y5W4JF9W;6-=>f|d+cICxU_v1S&!8~* zC8L4@+y@{zEI!!0nyrw9QDkx=zj(a@1Ke`3HYEZc$FNv{k%2)3=oh##zK(vLZmz*0 zdcJOi9Fp2UG5S!72-pkXqW=0p{Q{F5_?eGerZ#NZJN>d*s%R6Vx8lP`OJe1mlU8h1aod); z{r|sXei0h6FW)^CSk#yPYnK!=f|GVbY3K5+4Q?d#$af3~#V;KyOXjQ!K|BUGns zTG_&TNpHooXW6G;=asnnHs6_NvC!p3^%b_&hO9+f7aVol7j@ZllABcEr$*<){gr}J z-#9V~^)8!T6;(eI^^QBR*tF>2_xURmPI3GCg>1O+s^iN)b+ZeiQ71n|d_HEe(Z50B zMWVlomBtG0!&5cB{aUj(dscn-K8+=7-=7ye60^hM^#u{r?Q0@-$mn%1yHWVH;mdiq z>zs*SG)*t%3ogAhU&uZ zrEK+kSu^pVQs*+2RDmPGcX~HZ@%yB6u($4j!KVBB%rbZ$nNE1WdWr#?Cs)bNE)Axn zHj@rxuNw>*GHySo9Q(|uz;VQrO)&Nn&)rQa3BuM}&M%yBCc*PkL1X{H7B`dV-E)j* z9W}epBh0pe>rzi!rO1*Er!I#c;z+3EEApz3lwd9KT&8@a-_Ue|x={K7F-MV|lY)(o z^B(`}JXg3$rP*CwouS+h5MXh!b@H|wx2$6-_mNK z;Ie~8GlZW#*!QQ~mh|C5)3+;k)C5+%=m>N#NJxvbAb`K2%~XLUhC;GsELmdea$ zrm*%g)E`=q^5)<=b<4$mHd5&-zMn(gCjVgj{kP|o!gU7c<$h09-pzWGrtEoL>_*lj zDZ_^6tOx&IdG%@v_Zk@`+zwbR=%=+Cm z`MiC(KyR#3x%p1l!XxG3y|wG3cf_7aI9%o>$G=4?`({T{%ZkNjYrpNCyz;sDsgB%0 zsn_k5KR0}JJg|A;^Z768A3U|(CA-NYVx#M%pZD}mhUz1n+w7weU`nz8QJ|z=Wl4?bTjEUv2M3}odf?l z+)`ij?qhk?d>_7NGS@bX%6|BA?7w@WEgns2x1XmKQl6kFoR4H_fUWt!SL464ap36Wd*4c z@YQ)R!$jRdH8u>qb)1ULI7B^#+sMhs1mt)TCM{`vG5I;8k~-WaASSlq#ykZbomnNtRI1~j+A9kitJ8$Z-g!O5rixXcJAQC46{0ZIV848g#J J^-Tc80|1vN+8qD@ delta 1128 zcmcbwvsleJz?+$civa|b^V?j(3@E_~qM@LPzg<$2*c!mLSmD> z*^V%BP5vt&UeCagoRe5woLUUl1Th<3lOWJ+m?{)IuqzkEro1>g7n=>nQ0Ma^J0GSN zyBFjqTM8*{c4aJPk-+9daiB?)`5484DhgTLum=`DvTeDEWvO{cZbJ`QMWA_gJW8^- z!(I=fV)B0;Ua%^NyMN@N#*isO6?;K`u3klM&gS{NxlHV!Ah1D5PL>wqn_M8!6N}yL zpooS50fx7ZAQn=TFfxfSBSOvV=;5FZKm`Jf3=AR=Jz#Q4qdGVlum^a9G{6!9LZ5GbX>x?HeeJ0(@hq#htbmlDB(Z=$TkUXB->!g z2;o$8&5AI+3?R*}+~8!Ti_bb>!h!;jjuwaxP%@irBPxe*3(#8^5OT1@HF-apnkqi9 cjbQ!2G$t0{%?k800|PG*Zf0g+hyt4k0Aq-+5C8xG diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-f-plugin-1.0.0.smp b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-f-plugin-1.0.0.smp index cfcaae8427b446944574f877a2904199c48a28ed..8f2758f9624cbd31cb9e00921945e304d637262b 100644 GIT binary patch delta 1698 zcmb7E&ui0g6n|+NQ;liTlC~%^i#mp&9Rmj{I?Z82?PddZg2G^9R2{6YHY)QN9SGja zSH0>XcvBBMDE|02cJ(%T$u6?9zHid+HyI>d_CiZb`FuX_ectoB$*-)_>h zzWA$%xGVq|DE>q%1;P)|uLR-Vy&r<_azl*@+so~AUd2vcK`Qc9uY_Zt{W-NTZ0w6# zRK30Qy!AMpJ|0L#z)_V`qugU^RE!uu4Y5YeQO3je5)kC0QkKUK0Zaw}(kRLSsG02O z=3nK0M((2DR{~V6v(~P4x@(N1gGkuOVrP*m8~Lg_URS;Fa*$rgY~-kp_j8 z>s*q-k4<(YjY(ni`CF|RB6DAnXqGNGIfd)fa|bj%fN1ux~TPeA@C z>TJq$^B1*_|75$)oOmGvFvuP!bsbP?S4e8?8$@!qI1-u2b-rl>Y~gzPI9hcHR|}xx Ma|e&_vrfPL2aCCNr~m)} delta 865 zcmZ8fze~eF6uvaI^;gq!#RA2T`NVH@FCPaQD4Cd%gDEaJhHi``&l&%j5j#^gyMghKFg~wAP0RA#S0XUnrKH z9Hvb&22F5KFG{WUP78$Tm+;n)TDO!@5To^-JC{iSy*4 zlkYr!iLue!vDDoEC2WB|Bi~9xt(26Isj~THaH>3zFRsWl7AW25fC!?N71}n&qY{m7 z^#BeKV9S6K`oIGjo}iD=&0WD}{NKRAza?w!jW8iEpfBC2ggx*-kUK-2@s!@p2mW@1 zyCXN9MSFN))30HvX~(S28*PC_s5QdOlgSLQPa>-_L~el(feXI?;lIACE`gR3v-Z85tNvkd5(m^z(Fc4Gz)sbpyMj3m9}b z+%Yk7HzO#xMeBKyO(;&zB`(;o27)-cQ3W}r>6v+Y6}dU^a53GD8kOwOxP%3a1wXPO zxrt?|dBnL0w$IXlMwZJNKv57#=u|-Ho*)M^$xg$eDgQ% zC?@tIV1(F!br?_X;^QKiU?xxDYX--ZxV-`>F2UffBZwkCn=mqoFoR>8ACysG;H{(E z{)=fgzL2{2hDD%L;TgMN;)Bw-ZM23n?04UeM zz>-F5V1__3gNQr?FAm`W#O5iuVFJKh1v3j6=@vYb_j4;Fq77#j zLl^+eWJm@~2N__1NclJnnEYK(jt`-4Nh32aP@g%$d^R~s2vy%MexN=RP;N7WCv}i> Wu%q_q@B(2lFnw$k0Pz6Uw}q4d delta 842 zcmbQGb4!&qz?+$cYw`zfQF~4X1_oc(5Jz24KR11lum}SO5Kf)g?h2HF0}&t#uFTib z&(qB{I7H9aZL%fXZbq<4f*e34$vKI|#i_+$gTSJUf|Dl-iXiM`6b14n5PTh8WP^*7 zbCKLI4b2VmAgz;mgt#_OU{7R`067y~i8#=-$^Qk#fhyuz!0r+kLvna-Vp(b)!g0)s zK-CJoN(kpL>w!h|Wf38fhvo`XunM!yWxP>L>_F=nY`_A>leq-ACYuN}LqbCu6s`~; z!0^@)#DWLkt4a4WOAvtcm@cZi@OOfA@}65QaJL^4cN5Ez$G z05UEdtU`bCcM&;$m^jFJi^1Y%ldD8g#jW^2CW50?EWn!;=voE_ULf4e%)lTBHUR)L C>7GOY From 01379caa08d17a3ec4c2fb671a989dec7ac2af96 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 29 Jul 2019 12:54:58 +0200 Subject: [PATCH 012/135] implement first diff details --- .../sonia/scm/repository/api/DiffFile.java | 4 +- .../java/sonia/scm/repository/spi/Differ.java | 113 ++++++++++++++++++ .../repository/spi/GitDiffResultCommand.java | 85 +++++++++++++ .../spi/GitDiffResultCommandTest.java | 42 +++++++ 4 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java index d1d223e272..a3b1bafe0b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffFile.java @@ -6,7 +6,7 @@ public interface DiffFile extends Iterable { String getNewRevision(); - String getOldName(); + String getOldPath(); - String getNewName(); + String getNewPath(); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java new file mode 100644 index 0000000000..67d503aeff --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java @@ -0,0 +1,113 @@ +package sonia.scm.repository.spi; + +import com.google.common.base.Strings; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.EmptyTreeIterator; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.filter.PathFilter; +import sonia.scm.repository.GitUtil; +import sonia.scm.util.Util; + +import java.io.IOException; +import java.util.List; +import java.util.function.Consumer; + +final class Differ implements AutoCloseable { + + private final RevWalk walk; + private final TreeWalk treeWalk; + private final RevCommit commit; + + private Differ(RevCommit commit, RevWalk walk, TreeWalk treeWalk) { + this.commit = commit; + this.walk = walk; + this.treeWalk = treeWalk; + } + + public static Differ create(Repository repository, DiffCommandRequest request) throws IOException { + RevWalk walk = new RevWalk(repository); + + ObjectId revision = repository.resolve(request.getRevision()); + RevCommit commit = walk.parseCommit(revision); + + walk.markStart(commit); + commit = walk.next(); + TreeWalk treeWalk = new TreeWalk(repository); + treeWalk.reset(); + treeWalk.setRecursive(true); + + if (Util.isNotEmpty(request.getPath())) + { + treeWalk.setFilter(PathFilter.create(request.getPath())); + } + + + if (!Strings.isNullOrEmpty(request.getAncestorChangeset())) + { + ObjectId otherRevision = repository.resolve(request.getAncestorChangeset()); + ObjectId ancestorId = computeCommonAncestor(repository, revision, otherRevision); + RevTree tree = walk.parseCommit(ancestorId).getTree(); + treeWalk.addTree(tree); + } + else if (commit.getParentCount() > 0) + { + RevTree tree = commit.getParent(0).getTree(); + + if (tree != null) + { + treeWalk.addTree(tree); + } + else + { + treeWalk.addTree(new EmptyTreeIterator()); + } + } + else + { + treeWalk.addTree(new EmptyTreeIterator()); + } + + treeWalk.addTree(commit.getTree()); + + return new Differ(commit, walk, treeWalk); + } + + private static ObjectId computeCommonAncestor(org.eclipse.jgit.lib.Repository repository, ObjectId revision1, ObjectId revision2) throws IOException { + return GitUtil.computeCommonAncestor(repository, revision1, revision2); + } + + public void process(Consumer diffConsumer) throws IOException { + List entries = DiffEntry.scan(treeWalk); + diffConsumer.accept(new Diff(commit, entries)); + } + + @Override + public void close() { + GitUtil.release(walk); + GitUtil.release(treeWalk); + } + + public static class Diff { + + private final RevCommit commit; + private final List entries; + + private Diff(RevCommit commit, List entries) { + this.commit = commit; + this.entries = entries; + } + + public RevCommit getCommit() { + return commit; + } + + public List getEntries() { + return entries; + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java new file mode 100644 index 0000000000..a7c8cc3f84 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java @@ -0,0 +1,85 @@ +package sonia.scm.repository.spi; + +import org.eclipse.jgit.diff.DiffEntry; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.Repository; +import sonia.scm.repository.api.DiffFile; +import sonia.scm.repository.api.DiffResult; +import sonia.scm.repository.api.Hunk; + +import java.io.IOException; +import java.util.Iterator; +import java.util.stream.Collectors; + +public class GitDiffResultCommand extends AbstractGitCommand implements DiffResultCommand { + + GitDiffResultCommand(GitContext context, Repository repository) { + super(context, repository); + } + + public DiffResult getDiffResult(DiffCommandRequest diffCommandRequest) throws IOException { + try (Differ differ = Differ.create(open(), diffCommandRequest)) { + GitDiffResult result = new GitDiffResult(); + differ.process(result::process); + return result; + } + } + + private class GitDiffResult implements DiffResult { + + private Differ.Diff diff; + + void process(Differ.Diff diff) { + this.diff = diff; + } + + @Override + public String getOldRevision() { + return GitUtil.getId(diff.getCommit().getParent(0).getId()); + } + + @Override + public String getNewRevision() { + return GitUtil.getId(diff.getCommit().getId()); + } + + @Override + public Iterator iterator() { + return diff.getEntries().stream().map(GitDiffFile::new).collect(Collectors.toList()).iterator(); + } + } + + private static class GitDiffFile implements DiffFile { + + private final DiffEntry diffEntry; + + private GitDiffFile(DiffEntry diffEntry) { + this.diffEntry = diffEntry; + } + + @Override + public String getOldRevision() { + return null; + } + + @Override + public String getNewRevision() { + return null; + } + + @Override + public String getOldPath() { + return diffEntry.getOldPath(); + } + + @Override + public String getNewPath() { + return diffEntry.getNewPath(); + } + + @Override + public Iterator iterator() { + return null; + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java new file mode 100644 index 0000000000..a8b90d2b5c --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java @@ -0,0 +1,42 @@ +package sonia.scm.repository.spi; + +import org.junit.Test; +import sonia.scm.repository.api.DiffFile; +import sonia.scm.repository.api.DiffResult; + +import java.io.IOException; +import java.util.Iterator; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GitDiffResultCommandTest extends AbstractGitCommandTestBase { + + @Test + public void shouldReturnOldAndNewRevision() throws IOException { + GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext(), repository); + DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); + diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + + DiffResult diffResult = gitDiffResultCommand.getDiffResult(diffCommandRequest); + + assertThat(diffResult.getNewRevision()).isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + assertThat(diffResult.getOldRevision()).isEqualTo("592d797cd36432e591416e8b2b98154f4f163411"); + } + + @Test + public void shouldReturnFilePaths() throws IOException { + GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext(), repository); + DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); + diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + + DiffResult diffResult = gitDiffResultCommand.getDiffResult(diffCommandRequest); + Iterator iterator = diffResult.iterator(); + DiffFile a = iterator.next(); + assertThat(a.getNewPath()).isEqualTo("a.txt"); + assertThat(a.getOldPath()).isEqualTo("a.txt"); + + DiffFile b = iterator.next(); + assertThat(b.getOldPath()).isEqualTo("b.txt"); + assertThat(b.getNewPath()).isEqualTo("/dev/null"); + } +} From 07068880bb702b495d3967245b536d13c150d808 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 29 Jul 2019 16:42:49 +0200 Subject: [PATCH 013/135] implemented parsing of git diff hunks --- .../java/sonia/scm/repository/GitUtil.java | 3 +- .../sonia/scm/repository/spi/FileRange.java | 20 ++ .../repository/spi/GitDiffResultCommand.java | 42 ++++- .../sonia/scm/repository/spi/GitHunk.java | 48 +++++ .../scm/repository/spi/GitHunkParser.java | 175 ++++++++++++++++++ .../spi/GitDiffResultCommandTest.java | 67 ++++++- .../scm/repository/spi/GitHunkParserTest.java | 138 ++++++++++++++ 7 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileRange.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunk.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHunkParserTest.java diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index 7aacdb256a..7175d3b646 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -43,6 +43,7 @@ import org.eclipse.jgit.api.FetchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; @@ -441,7 +442,7 @@ public final class GitUtil * * @return */ - public static String getId(ObjectId objectId) + public static String getId(AnyObjectId objectId) { String id = Util.EMPTY_STRING; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileRange.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileRange.java new file mode 100644 index 0000000000..8d445e1c44 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FileRange.java @@ -0,0 +1,20 @@ +package sonia.scm.repository.spi; + +public class FileRange { + + private final int start; + private final int lineCount; + + public FileRange(int start, int lineCount) { + this.start = start; + this.lineCount = lineCount; + } + + public int getStart() { + return start; + } + + public int getLineCount() { + return lineCount; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java index a7c8cc3f84..de7c8483f5 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java @@ -1,12 +1,15 @@ package sonia.scm.repository.spi; +import com.google.common.base.Throwables; import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffFormatter; import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.api.DiffFile; import sonia.scm.repository.api.DiffResult; import sonia.scm.repository.api.Hunk; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Iterator; import java.util.stream.Collectors; @@ -18,8 +21,9 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu } public DiffResult getDiffResult(DiffCommandRequest diffCommandRequest) throws IOException { - try (Differ differ = Differ.create(open(), diffCommandRequest)) { - GitDiffResult result = new GitDiffResult(); + org.eclipse.jgit.lib.Repository repository = open(); + try (Differ differ = Differ.create(repository, diffCommandRequest)) { + GitDiffResult result = new GitDiffResult(repository); differ.process(result::process); return result; } @@ -27,8 +31,13 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu private class GitDiffResult implements DiffResult { + private final org.eclipse.jgit.lib.Repository repository; private Differ.Diff diff; + private GitDiffResult(org.eclipse.jgit.lib.Repository repository) { + this.repository = repository; + } + void process(Differ.Diff diff) { this.diff = diff; } @@ -45,26 +54,28 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu @Override public Iterator iterator() { - return diff.getEntries().stream().map(GitDiffFile::new).collect(Collectors.toList()).iterator(); + return diff.getEntries().stream().map(diffEntry -> new GitDiffFile(repository, diffEntry)).collect(Collectors.toList()).iterator(); } } - private static class GitDiffFile implements DiffFile { + private class GitDiffFile implements DiffFile { + private final org.eclipse.jgit.lib.Repository repository; private final DiffEntry diffEntry; - private GitDiffFile(DiffEntry diffEntry) { + private GitDiffFile(org.eclipse.jgit.lib.Repository repository, DiffEntry diffEntry) { + this.repository = repository; this.diffEntry = diffEntry; } @Override public String getOldRevision() { - return null; + return GitUtil.getId(diffEntry.getOldId().toObjectId()); } @Override public String getNewRevision() { - return null; + return GitUtil.getId(diffEntry.getNewId().toObjectId()); } @Override @@ -79,7 +90,22 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu @Override public Iterator iterator() { - return null; + String content = format(repository, diffEntry); + GitHunkParser parser = new GitHunkParser(); + return parser.parse(content).iterator(); } + + private String format(org.eclipse.jgit.lib.Repository repository, DiffEntry entry) { + try(ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + DiffFormatter formatter = new DiffFormatter(baos); + formatter.setRepository(repository); + formatter.format(entry); + return baos.toString(); + } catch (IOException ex) { + throw Throwables.propagate(ex); + } + } + } + } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunk.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunk.java new file mode 100644 index 0000000000..9a272d7b10 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunk.java @@ -0,0 +1,48 @@ +package sonia.scm.repository.spi; + +import sonia.scm.repository.api.DiffLine; +import sonia.scm.repository.api.Hunk; + +import java.util.Iterator; +import java.util.List; + +public class GitHunk implements Hunk { + + private final FileRange oldFileRange; + private final FileRange newFileRange; + private List lines; + + public GitHunk(FileRange oldFileRange, FileRange newFileRange) { + this.oldFileRange = oldFileRange; + this.newFileRange = newFileRange; + } + + @Override + public int getOldStart() { + return oldFileRange.getStart(); + } + + @Override + public int getOldLineCount() { + return oldFileRange.getLineCount(); + } + + @Override + public int getNewStart() { + return newFileRange.getStart(); + } + + @Override + public int getNewLineCount() { + return newFileRange.getLineCount(); + } + + @Override + public Iterator iterator() { + return lines.iterator(); + } + + void setLines(List lines) { + this.lines = lines; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java new file mode 100644 index 0000000000..caedc6605f --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java @@ -0,0 +1,175 @@ +package sonia.scm.repository.spi; + +import sonia.scm.repository.api.DiffLine; +import sonia.scm.repository.api.Hunk; + +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalInt; +import java.util.Scanner; + +import static java.util.OptionalInt.of; + +final class GitHunkParser { + private static final int HEADER_PREFIX_LENGTH = "@@ -".length(); + private static final int HEADER_SUFFIX_LENGTH = " @@".length(); + + private GitHunk currentGitHunk = null; + private List collectedLines = null; + private int oldLineCounter = 0; + private int newLineCounter = 0; + + GitHunkParser() { + } + + public List parse(String content) { + List hunks = new ArrayList<>(); + + try (Scanner scanner = new Scanner(content)) { + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + if (line.startsWith("@@")) { + parseHeader(hunks, line); + } else if (currentGitHunk != null) { + parseDiffLine(line); + } + } + } + if (currentGitHunk != null) { + currentGitHunk.setLines(collectedLines); + } + + return hunks; + } + + private void parseHeader(List hunks, String line) { + if (currentGitHunk != null) { + currentGitHunk.setLines(collectedLines); + } + String hunkHeader = line.substring(HEADER_PREFIX_LENGTH, line.length() - HEADER_SUFFIX_LENGTH); + String[] split = hunkHeader.split("\\s"); + + FileRange oldFileRange = createFileRange(split[0]); + // TODO merge contains two two block which starts with "-" e.g. -1,3 -2,4 +3,6 + FileRange newFileRange = createFileRange(split[1]); + + currentGitHunk = new GitHunk(oldFileRange, newFileRange); + hunks.add(currentGitHunk); + + collectedLines = new ArrayList<>(); + oldLineCounter = currentGitHunk.getOldStart(); + newLineCounter = currentGitHunk.getNewStart(); + } + + private void parseDiffLine(String line) { + String content = line.substring(1); + switch (line.charAt(0)) { + case ' ': + collectedLines.add(new UnchangedGitDiffLine(newLineCounter, oldLineCounter, content)); + ++newLineCounter; + ++oldLineCounter; + break; + case '+': + collectedLines.add(new AddedGitDiffLine(newLineCounter, content)); + ++newLineCounter; + break; + case '-': + collectedLines.add(new RemovedGitDiffLine(oldLineCounter, content)); + ++oldLineCounter; + break; + default: + throw new IllegalStateException("cannot handle diff line: " + line); + } + } + + private static class AddedGitDiffLine implements DiffLine { + private final int newLineNumber; + private final String content; + + private AddedGitDiffLine(int newLineNumber, String content) { + this.newLineNumber = newLineNumber; + this.content = content; + } + + @Override + public OptionalInt getOldLineNumber() { + return OptionalInt.empty(); + } + + @Override + public OptionalInt getNewLineNumber() { + return of(newLineNumber); + } + + @Override + public String getContent() { + return content; + } + } + + private static class RemovedGitDiffLine implements DiffLine { + private final int oldLineNumber; + private final String content; + + private RemovedGitDiffLine(int oldLineNumber, String content) { + this.oldLineNumber = oldLineNumber; + this.content = content; + } + + @Override + public OptionalInt getOldLineNumber() { + return of(oldLineNumber); + } + + @Override + public OptionalInt getNewLineNumber() { + return OptionalInt.empty(); + } + + @Override + public String getContent() { + return content; + } + } + + private static class UnchangedGitDiffLine implements DiffLine { + private final int newLineNumber; + private final int oldLineNumber; + private final String content; + + public UnchangedGitDiffLine(int newLineNumber, int oldLineNumber, String content) { + this.newLineNumber = newLineNumber; + this.oldLineNumber = oldLineNumber; + this.content = content; + } + + @Override + public OptionalInt getOldLineNumber() { + return of(oldLineNumber); + } + + @Override + public OptionalInt getNewLineNumber() { + return of(newLineNumber); + } + + @Override + public String getContent() { + return content; + } + } + + private static FileRange createFileRange(String fileRangeString) { + int start; + int lineCount = 1; + int commaIndex = fileRangeString.indexOf(','); + if (commaIndex > 0) { + start = Integer.parseInt(fileRangeString.substring(0, commaIndex)); + lineCount = Integer.parseInt(fileRangeString.substring(commaIndex + 1)); + } else { + start = Integer.parseInt(fileRangeString); + } + + return new FileRange(start, lineCount); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java index a8b90d2b5c..f359ae987c 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java @@ -3,6 +3,7 @@ package sonia.scm.repository.spi; import org.junit.Test; import sonia.scm.repository.api.DiffFile; import sonia.scm.repository.api.DiffResult; +import sonia.scm.repository.api.Hunk; import java.io.IOException; import java.util.Iterator; @@ -13,11 +14,7 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase { @Test public void shouldReturnOldAndNewRevision() throws IOException { - GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext(), repository); - DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); - diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); - - DiffResult diffResult = gitDiffResultCommand.getDiffResult(diffCommandRequest); + DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); assertThat(diffResult.getNewRevision()).isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); assertThat(diffResult.getOldRevision()).isEqualTo("592d797cd36432e591416e8b2b98154f4f163411"); @@ -25,11 +22,7 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase { @Test public void shouldReturnFilePaths() throws IOException { - GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext(), repository); - DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); - diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); - - DiffResult diffResult = gitDiffResultCommand.getDiffResult(diffCommandRequest); + DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); Iterator iterator = diffResult.iterator(); DiffFile a = iterator.next(); assertThat(a.getNewPath()).isEqualTo("a.txt"); @@ -39,4 +32,58 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase { assertThat(b.getOldPath()).isEqualTo("b.txt"); assertThat(b.getNewPath()).isEqualTo("/dev/null"); } + + @Test + public void shouldReturnFileRevisions() throws IOException { + DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + Iterator iterator = diffResult.iterator(); + + DiffFile a = iterator.next(); + assertThat(a.getOldRevision()).isEqualTo("78981922613b2afb6025042ff6bd878ac1994e85"); + assertThat(a.getNewRevision()).isEqualTo("1dc60c7504f4326bc83b9b628c384ec8d7e57096"); + + DiffFile b = iterator.next(); + assertThat(b.getOldRevision()).isEqualTo("61780798228d17af2d34fce4cfbdf35556832472"); + assertThat(b.getNewRevision()).isEqualTo("0000000000000000000000000000000000000000"); + } + + @Test + public void shouldReturnFileHunks() throws IOException { + DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + Iterator iterator = diffResult.iterator(); + + DiffFile a = iterator.next(); + Iterator hunks = a.iterator(); + + Hunk hunk = hunks.next(); + assertThat(hunk.getOldStart()).isEqualTo(1); + assertThat(hunk.getOldLineCount()).isEqualTo(1); + + assertThat(hunk.getNewStart()).isEqualTo(1); + assertThat(hunk.getNewLineCount()).isEqualTo(1); + } + + @Test + public void shouldReturnFileHunksWithFullFileRange() throws IOException { + DiffResult diffResult = createDiffResult("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + Iterator iterator = diffResult.iterator(); + + DiffFile a = iterator.next(); + Iterator hunks = a.iterator(); + + Hunk hunk = hunks.next(); + assertThat(hunk.getOldStart()).isEqualTo(1); + assertThat(hunk.getOldLineCount()).isEqualTo(1); + + assertThat(hunk.getNewStart()).isEqualTo(1); + assertThat(hunk.getNewLineCount()).isEqualTo(2); + } + + private DiffResult createDiffResult(String s) throws IOException { + GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext(), repository); + DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); + diffCommandRequest.setRevision(s); + + return gitDiffResultCommand.getDiffResult(diffCommandRequest); + } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHunkParserTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHunkParserTest.java new file mode 100644 index 0000000000..a58fae644d --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHunkParserTest.java @@ -0,0 +1,138 @@ +package sonia.scm.repository.spi; + +import org.junit.jupiter.api.Test; +import sonia.scm.repository.api.DiffLine; +import sonia.scm.repository.api.Hunk; + +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class GitHunkParserTest { + + private static final String DIFF_001 = "diff --git a/a.txt b/a.txt\n" + + "index 7898192..2f8bc28 100644\n" + + "--- a/a.txt\n" + + "+++ b/a.txt\n" + + "@@ -1 +1,2 @@\n" + + " a\n" + + "+added line\n"; + + private static final String DIFF_002 = "diff --git a/file b/file\n" + + "index 5e89957..e8823e1 100644\n" + + "--- a/file\n" + + "+++ b/file\n" + + "@@ -2,6 +2,9 @@\n" + + " 2\n" + + " 3\n" + + " 4\n" + + "+5\n" + + "+6\n" + + "+7\n" + + " 8\n" + + " 9\n" + + " 10\n" + + "@@ -15,14 +18,13 @@\n" + + " 18\n" + + " 19\n" + + " 20\n" + + "+21\n" + + "+22\n" + + " 23\n" + + " 24\n" + + " 25\n" + + " 26\n" + + " 27\n" + + "-a\n" + + "-b\n" + + "-c\n" + + " 28\n" + + " 29\n" + + " 30"; + + private static final String DIFF_003 = "diff --git a/a.txt b/a.txt\n" + + "index 7898192..2f8bc28 100644\n" + + "--- a/a.txt\n" + + "+++ b/a.txt\n" + + "@@ -1,2 +1 @@\n" + + " a\n" + + "-removed line\n"; + + private static final String ILLEGAL_DIFF = "diff --git a/a.txt b/a.txt\n" + + "index 7898192..2f8bc28 100644\n" + + "--- a/a.txt\n" + + "+++ b/a.txt\n" + + "@@ -1,2 +1 @@\n" + + " a\n" + + "~illegal line\n"; + + @Test + void shouldParseHunks() { + List hunks = new GitHunkParser().parse(DIFF_001); + assertThat(hunks).hasSize(1); + assertHunk(hunks.get(0), 1, 1, 1, 2); + } + + @Test + void shouldParseMultipleHunks() { + List hunks = new GitHunkParser().parse(DIFF_002); + + assertThat(hunks).hasSize(2); + assertHunk(hunks.get(0), 2, 6, 2, 9); + assertHunk(hunks.get(1), 15, 14, 18, 13); + } + + @Test + void shouldParseAddedHunkLines() { + List hunks = new GitHunkParser().parse(DIFF_001); + + Hunk hunk = hunks.get(0); + + Iterator lines = hunk.iterator(); + + DiffLine line1 = lines.next(); + assertThat(line1.getOldLineNumber()).hasValue(1); + assertThat(line1.getNewLineNumber()).hasValue(1); + assertThat(line1.getContent()).isEqualTo("a"); + + DiffLine line2 = lines.next(); + assertThat(line2.getOldLineNumber()).isEmpty(); + assertThat(line2.getNewLineNumber()).hasValue(2); + assertThat(line2.getContent()).isEqualTo("added line"); + } + + @Test + void shouldParseRemovedHunkLines() { + List hunks = new GitHunkParser().parse(DIFF_003); + + Hunk hunk = hunks.get(0); + + Iterator lines = hunk.iterator(); + + DiffLine line1 = lines.next(); + assertThat(line1.getOldLineNumber()).hasValue(1); + assertThat(line1.getNewLineNumber()).hasValue(1); + assertThat(line1.getContent()).isEqualTo("a"); + + DiffLine line2 = lines.next(); + assertThat(line2.getOldLineNumber()).hasValue(2); + assertThat(line2.getNewLineNumber()).isEmpty(); + assertThat(line2.getContent()).isEqualTo("removed line"); + } + + @Test + void shouldFailForIllegalLine() { + assertThrows(IllegalStateException.class, () -> new GitHunkParser().parse(ILLEGAL_DIFF)); + } + + private void assertHunk(Hunk hunk, int oldStart, int oldLineCount, int newStart, int newLineCount) { + assertThat(hunk.getOldStart()).isEqualTo(oldStart); + assertThat(hunk.getOldLineCount()).isEqualTo(oldLineCount); + + assertThat(hunk.getNewStart()).isEqualTo(newStart); + assertThat(hunk.getNewLineCount()).isEqualTo(newLineCount); + } + +} From e3787fd764f92cd3ee9d104870d83d3b241e9cfd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 30 Jul 2019 08:06:10 +0200 Subject: [PATCH 014/135] simplify Differ api and use the new api in GitDiffCommand --- .../java/sonia/scm/repository/spi/Differ.java | 13 ++-- .../scm/repository/spi/GitDiffCommand.java | 72 ++----------------- .../repository/spi/GitDiffResultCommand.java | 15 ++-- .../scm/repository/spi/GitHunkParser.java | 3 +- 4 files changed, 20 insertions(+), 83 deletions(-) diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java index 67d503aeff..ca417550f4 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java @@ -15,7 +15,6 @@ import sonia.scm.util.Util; import java.io.IOException; import java.util.List; -import java.util.function.Consumer; final class Differ implements AutoCloseable { @@ -29,7 +28,13 @@ final class Differ implements AutoCloseable { this.treeWalk = treeWalk; } - public static Differ create(Repository repository, DiffCommandRequest request) throws IOException { + static Diff diff(Repository repository, DiffCommandRequest request) throws IOException { + try (Differ differ = create(repository, request)) { + return differ.diff(); + } + } + + private static Differ create(Repository repository, DiffCommandRequest request) throws IOException { RevWalk walk = new RevWalk(repository); ObjectId revision = repository.resolve(request.getRevision()); @@ -81,9 +86,9 @@ final class Differ implements AutoCloseable { return GitUtil.computeCommonAncestor(repository, revision1, revision2); } - public void process(Consumer diffConsumer) throws IOException { + private Diff diff() throws IOException { List entries = DiffEntry.scan(treeWalk); - diffConsumer.accept(new Diff(commit, entries)); + return new Diff(commit, entries); } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java index 2d56c8e786..7d5e45a5c2 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java @@ -34,26 +34,15 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.base.Strings; import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffFormatter; -import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevTree; -import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.treewalk.EmptyTreeIterator; -import org.eclipse.jgit.treewalk.TreeWalk; -import org.eclipse.jgit.treewalk.filter.PathFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; -import sonia.scm.util.Util; import java.io.BufferedOutputStream; -import java.io.IOException; import java.io.OutputStream; -import java.util.List; /** * @@ -78,7 +67,7 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand * @param context * @param repository */ - public GitDiffCommand(GitContext context, Repository repository) + GitDiffCommand(GitContext context, Repository repository) { super(context, repository); } @@ -95,63 +84,18 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand @Override public void getDiffResult(DiffCommandRequest request, OutputStream output) { - RevWalk walk = null; - TreeWalk treeWalk = null; DiffFormatter formatter = null; try { - org.eclipse.jgit.lib.Repository gr = open(); + org.eclipse.jgit.lib.Repository repository = open(); - walk = new RevWalk(gr); - - ObjectId revision = gr.resolve(request.getRevision()); - RevCommit commit = walk.parseCommit(revision); - - walk.markStart(commit); - commit = walk.next(); - treeWalk = new TreeWalk(gr); - treeWalk.reset(); - treeWalk.setRecursive(true); - - if (Util.isNotEmpty(request.getPath())) - { - treeWalk.setFilter(PathFilter.create(request.getPath())); - } - - - if (!Strings.isNullOrEmpty(request.getAncestorChangeset())) - { - ObjectId otherRevision = gr.resolve(request.getAncestorChangeset()); - ObjectId ancestorId = computeCommonAncestor(gr, revision, otherRevision); - RevTree tree = walk.parseCommit(ancestorId).getTree(); - treeWalk.addTree(tree); - } - else if (commit.getParentCount() > 0) - { - RevTree tree = commit.getParent(0).getTree(); - - if (tree != null) - { - treeWalk.addTree(tree); - } - else - { - treeWalk.addTree(new EmptyTreeIterator()); - } - } - else - { - treeWalk.addTree(new EmptyTreeIterator()); - } - - treeWalk.addTree(commit.getTree()); formatter = new DiffFormatter(new BufferedOutputStream(output)); - formatter.setRepository(gr); + formatter.setRepository(repository); - List entries = DiffEntry.scan(treeWalk); + Differ.Diff diff = Differ.diff(repository, request); - for (DiffEntry e : entries) + for (DiffEntry e : diff.getEntries()) { if (!e.getOldId().equals(e.getNewId())) { @@ -168,14 +112,8 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand } finally { - GitUtil.release(walk); - GitUtil.release(treeWalk); GitUtil.release(formatter); } } - private ObjectId computeCommonAncestor(org.eclipse.jgit.lib.Repository repository, ObjectId revision1, ObjectId revision2) throws IOException { - return GitUtil.computeCommonAncestor(repository, revision1, revision2); - } - } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java index de7c8483f5..0d5f4f7b9e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java @@ -22,23 +22,16 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu public DiffResult getDiffResult(DiffCommandRequest diffCommandRequest) throws IOException { org.eclipse.jgit.lib.Repository repository = open(); - try (Differ differ = Differ.create(repository, diffCommandRequest)) { - GitDiffResult result = new GitDiffResult(repository); - differ.process(result::process); - return result; - } + return new GitDiffResult(repository, Differ.diff(repository, diffCommandRequest)); } private class GitDiffResult implements DiffResult { private final org.eclipse.jgit.lib.Repository repository; - private Differ.Diff diff; + private final Differ.Diff diff; - private GitDiffResult(org.eclipse.jgit.lib.Repository repository) { + private GitDiffResult(org.eclipse.jgit.lib.Repository repository, Differ.Diff diff) { this.repository = repository; - } - - void process(Differ.Diff diff) { this.diff = diff; } @@ -96,7 +89,7 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu } private String format(org.eclipse.jgit.lib.Repository repository, DiffEntry entry) { - try(ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { DiffFormatter formatter = new DiffFormatter(baos); formatter.setRepository(repository); formatter.format(entry); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java index caedc6605f..b7594bb5d6 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHunkParser.java @@ -51,6 +51,7 @@ final class GitHunkParser { FileRange oldFileRange = createFileRange(split[0]); // TODO merge contains two two block which starts with "-" e.g. -1,3 -2,4 +3,6 + // check if it is relevant for our use case FileRange newFileRange = createFileRange(split[1]); currentGitHunk = new GitHunk(oldFileRange, newFileRange); @@ -137,7 +138,7 @@ final class GitHunkParser { private final int oldLineNumber; private final String content; - public UnchangedGitDiffLine(int newLineNumber, int oldLineNumber, String content) { + private UnchangedGitDiffLine(int newLineNumber, int oldLineNumber, String content) { this.newLineNumber = newLineNumber; this.oldLineNumber = oldLineNumber; this.content = content; From 0b76cb7ea5b5af07544b76d37f2df4d9e84f2547 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 30 Jul 2019 10:00:36 +0200 Subject: [PATCH 015/135] added raw header to hunk --- .../java/sonia/scm/repository/api/Hunk.java | 12 +++++ .../sonia/scm/repository/api/HunkTest.java | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 scm-core/src/test/java/sonia/scm/repository/api/HunkTest.java diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java b/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java index 6e60f8b2bd..c8a3e1ebca 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Hunk.java @@ -2,6 +2,18 @@ package sonia.scm.repository.api; public interface Hunk extends Iterable { + default String getRawHeader() { + return String.format("@@ -%s +%s @@", getLineMarker(getOldStart(), getOldLineCount()), getLineMarker(getNewStart(), getNewLineCount())); + } + + default String getLineMarker(int start, int lineCount) { + if (lineCount == 1) { + return Integer.toString(start); + } else { + return String.format("%s,%s", start, lineCount); + } + } + int getOldStart(); int getOldLineCount(); diff --git a/scm-core/src/test/java/sonia/scm/repository/api/HunkTest.java b/scm-core/src/test/java/sonia/scm/repository/api/HunkTest.java new file mode 100644 index 0000000000..086df81741 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/api/HunkTest.java @@ -0,0 +1,53 @@ +package sonia.scm.repository.api; + +import org.junit.jupiter.api.Test; + +import java.util.Iterator; + +import static org.assertj.core.api.Assertions.assertThat; + +class HunkTest { + + @Test + void shouldGetComplexHeader() { + String rawHeader = createHunk(2, 3, 4, 5).getRawHeader(); + + assertThat(rawHeader).isEqualTo("@@ -2,3 +4,5 @@"); + } + + @Test + void shouldReturnSingleNumberForOne() { + String rawHeader = createHunk(42, 1, 5, 1).getRawHeader(); + + assertThat(rawHeader).isEqualTo("@@ -42 +5 @@"); + } + + private Hunk createHunk(int oldStart, int oldLineCount, int newStart, int newLineCount) { + return new Hunk() { + @Override + public int getOldStart() { + return oldStart; + } + + @Override + public int getOldLineCount() { + return oldLineCount; + } + + @Override + public int getNewStart() { + return newStart; + } + + @Override + public int getNewLineCount() { + return newLineCount; + } + + @Override + public Iterator iterator() { + return null; + } + }; + } +} From bbdc5a198908507d577a515103a95a2d8a002535 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 30 Jul 2019 10:01:00 +0200 Subject: [PATCH 016/135] fix Diff ui types --- .../packages/ui-components/src/repos/Diff.js | 4 ++-- .../packages/ui-components/src/repos/DiffTypes.js | 13 +++++++++---- .../packages/ui-components/src/repos/LoadingDiff.js | 7 ++++--- .../packages/ui-components/src/repos/index.js | 1 + 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/repos/Diff.js b/scm-ui-components/packages/ui-components/src/repos/Diff.js index 369e812344..97692210c2 100644 --- a/scm-ui-components/packages/ui-components/src/repos/Diff.js +++ b/scm-ui-components/packages/ui-components/src/repos/Diff.js @@ -1,10 +1,10 @@ //@flow import React from "react"; import DiffFile from "./DiffFile"; -import type { DiffObjectProps } from "./DiffTypes"; +import type { DiffObjectProps, File } from "./DiffTypes"; type Props = DiffObjectProps & { - diff: any + diff: File[] }; class Diff extends React.Component { diff --git a/scm-ui-components/packages/ui-components/src/repos/DiffTypes.js b/scm-ui-components/packages/ui-components/src/repos/DiffTypes.js index 74803e0a4e..dcd23bc9a8 100644 --- a/scm-ui-components/packages/ui-components/src/repos/DiffTypes.js +++ b/scm-ui-components/packages/ui-components/src/repos/DiffTypes.js @@ -27,12 +27,17 @@ export type Hunk = { content: string }; +export type ChangeType = "insert" | "delete" | "normal"; + export type Change = { content: string, - isNormal: boolean, - newLineNumber: number, - oldLineNumber: number, - type: string + isNormal?: boolean, + isInsert?: boolean, + isDelete?: boolean, + lineNumber?: number, + newLineNumber?: number, + oldLineNumber?: number, + type: ChangeType }; export type BaseContext = { diff --git a/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js b/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js index ce1a074b41..c8a5250756 100644 --- a/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js +++ b/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js @@ -6,14 +6,14 @@ import parser from "gitdiff-parser"; import Loading from "../Loading"; import Diff from "./Diff"; -import type {DiffObjectProps} from "./DiffTypes"; +import type {DiffObjectProps, File} from "./DiffTypes"; type Props = DiffObjectProps & { url: string }; type State = { - diff?: any, + diff?: File[], loading: boolean, error?: Error }; @@ -47,7 +47,8 @@ class LoadingDiff extends React.Component { .get(url) .then(response => response.text()) .then(parser.parse) - .then(diff => { + // $FlowFixMe + .then((diff: File[]) => { this.setState({ loading: false, diff: diff diff --git a/scm-ui-components/packages/ui-components/src/repos/index.js b/scm-ui-components/packages/ui-components/src/repos/index.js index 473bbb3efc..fd1448fbdd 100644 --- a/scm-ui-components/packages/ui-components/src/repos/index.js +++ b/scm-ui-components/packages/ui-components/src/repos/index.js @@ -12,6 +12,7 @@ export type { FileChangeType, Hunk, Change, + ChangeType, BaseContext, AnnotationFactory, AnnotationFactoryContext, From 598a4e6f321425ccd5a8ac2d5608031c3fc93774 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Tue, 30 Jul 2019 16:49:24 +0200 Subject: [PATCH 017/135] fix Mapping / implement endpoint --- .../sonia/scm/plugin/PluginInformation.java | 8 ++--- .../scm/api/v2/resources/PluginDtoMapper.java | 7 ++++ .../scm/api/v2/resources/PluginResource.java | 9 +++-- .../scm/plugin/DefaultPluginManager.java | 34 +++++-------------- .../sonia/scm/plugin/PluginCenterDto.java | 13 +++++-- .../scm/plugin/PluginCenterDtoMapper.java | 32 ++++++++++++++++- 6 files changed, 67 insertions(+), 36 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 663fd81f9f..74cca02d92 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -46,7 +46,7 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; -import java.util.List; +import java.util.Map; //~--- JDK imports ------------------------------------------------------------ @@ -288,7 +288,7 @@ public class PluginInformation * * @return */ - public List getLinks() { + public Map getLinks() { return links; } @@ -391,7 +391,7 @@ public class PluginInformation * * @param links */ - public void setLinks(List links) { + public void setLinks(Map links) { this.links = links; } @@ -419,6 +419,6 @@ public class PluginInformation private String version; /** Field description */ - private List links; + private Map links; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 7b2108cd0e..4886138a6b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -5,6 +5,8 @@ import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginWrapper; import javax.inject.Inject; +import java.util.Map; + import static de.otto.edison.hal.Links.linkingTo; public class PluginDtoMapper { @@ -25,6 +27,11 @@ public class PluginDtoMapper { .self(resourceLinks.plugin() .self(pluginInformation.getName())); + for (Object link : pluginInformation.getLinks().values()) { + System.out.println("Link is = " + link.toString()); + linksBuilder.item(((Map) link).values().iterator().next().toString()); + } + PluginDto pluginDto = new PluginDto(linksBuilder.build()); pluginDto.setName(pluginInformation.getName()); pluginDto.setCategory(pluginInformation.getCategory() != null ? pluginInformation.getCategory() : "Miscellaneous"); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java index 99c61191fa..be57e3f674 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java @@ -9,6 +9,7 @@ import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; +import sonia.scm.plugin.PluginState; import sonia.scm.plugin.PluginWrapper; import sonia.scm.web.VndMediaType; @@ -22,6 +23,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -34,7 +36,7 @@ public class PluginResource { private final PluginManager pluginManager; @Inject - public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper, PluginCenter pluginCenter1, PluginManager pluginManager) { + public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper, PluginManager pluginManager) { this.pluginLoader = pluginLoader; this.collectionMapper = collectionMapper; this.mapper = mapper; @@ -105,7 +107,10 @@ public class PluginResource { @Produces(VndMediaType.PLUGIN_COLLECTION) public Response getAvailablePlugins() { PluginPermissions.read().check(); - Collection plugins = pluginManager.getAvailable(); + Collection plugins = pluginManager.getAvailable() + .stream() + .filter(plugin -> plugin.getState().equals(PluginState.AVAILABLE)) + .collect(Collectors.toList()); return Response.ok(collectionMapper.map(plugins)).build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index a62916ed41..93e2e9aaa1 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -75,11 +75,14 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import javax.xml.bind.JAXB; import sonia.scm.net.ahc.AdvancedHttpClient; +import static sonia.scm.plugin.PluginCenterDtoMapper.*; + /** * TODO replace aether stuff. * TODO check AdvancedPluginConfiguration from 1.x @@ -592,8 +595,8 @@ public class DefaultPluginManager implements PluginManager { PluginCenter center = null; // cache.get(PluginCenter.class.getName()); - if (center == null) - { +// if (center == null) +// { synchronized (DefaultPluginManager.class) { String pluginUrl = configuration.getPluginUrl(); @@ -611,7 +614,8 @@ public class DefaultPluginManager implements PluginManager { center = new PluginCenter(); PluginCenterDto pluginCenterDto = httpClient.get(pluginUrl).request().contentFromJson(PluginCenterDto.class); - center.setPlugins(mapPluginsFromPluginCenter(pluginCenterDto.getEmbedded().getPlugins())); + Set pluginInformationSet = map(pluginCenterDto.getEmbedded().getPlugins()); + center.setPlugins(pluginInformationSet); preparePlugins(center); cache.put(PluginCenter.class.getName(), center); @@ -632,33 +636,11 @@ public class DefaultPluginManager implements PluginManager } } } - } +// } return center; } - private Set mapPluginsFromPluginCenter(List plugins) { - HashSet pluginInformationSet = new HashSet<>(); - - for (PluginCenterDto.Plugin plugin : plugins) { - - PluginInformation pluginInformation = new PluginInformation(); - pluginInformation.setName(plugin.getName()); - pluginInformation.setAuthor(plugin.getAuthor()); - pluginInformation.setCategory(plugin.getCategory()); - pluginInformation.setVersion(plugin.getVersion()); - pluginInformation.setDescription(plugin.getDescription()); - - if (plugin.getConditions() != null) { - PluginCenterDto.Condition condition = plugin.getConditions(); - pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), Collections.singletonList(condition.getOs()), condition.getArch())); - } - - pluginInformationSet.add(pluginInformation); - } - return pluginInformationSet; - } - /** * Method description * diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index 468249157a..f15c646c03 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -9,6 +9,7 @@ import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; import java.util.List; +import java.util.Map; @XmlRootElement @XmlAccessorType(XmlAccessType.FIELD) @@ -56,8 +57,7 @@ public final class PluginCenterDto implements Serializable { private Dependency dependencies; @XmlElement(name = "_links") - private Links links; - + private Map links; } @XmlAccessorType(XmlAccessType.FIELD) @@ -81,7 +81,14 @@ public final class PluginCenterDto implements Serializable { @XmlRootElement(name = "_links") @Getter static class Links { - private String download; + private Link link; + private boolean templated; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @Getter + static class Link { + private String url; } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java index 77593fe56a..cca5da6520 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -1,5 +1,35 @@ package sonia.scm.plugin; -public class PluginCenterDtoMapper { +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +public class PluginCenterDtoMapper { + + public static Set map(List plugins) { + HashSet pluginInformationSet = new HashSet<>(); + + for (PluginCenterDto.Plugin plugin : plugins) { + + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName(plugin.getName()); + pluginInformation.setAuthor(plugin.getAuthor()); + pluginInformation.setCategory(plugin.getCategory()); + pluginInformation.setVersion(plugin.getVersion()); + pluginInformation.setDescription(plugin.getDescription()); + + if (plugin.getConditions() != null) { + PluginCenterDto.Condition condition = plugin.getConditions(); + pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), Collections.singletonList(condition.getOs()), condition.getArch())); + } + + if (plugin.getLinks() != null) { + pluginInformation.setLinks(plugin.getLinks()); + } + + pluginInformationSet.add(pluginInformation); + } + return pluginInformationSet; + } } From a9744d8df1b08da4d81010bae2aa6fbc7067625d Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 31 Jul 2019 10:27:52 +0200 Subject: [PATCH 018/135] refactor plugin endpoints --- .../sonia/scm/plugin/PluginInformation.java | 24 ---- .../v2/resources/AvailablePluginResource.java | 106 ++++++++++++++++++ .../api/v2/resources/IndexDtoGenerator.java | 3 +- ...urce.java => InstalledPluginResource.java} | 30 +---- .../resources/PluginDtoCollectionMapper.java | 16 ++- .../scm/api/v2/resources/PluginDtoMapper.java | 21 ++-- .../api/v2/resources/PluginRootResource.java | 19 ++-- .../scm/api/v2/resources/ResourceLinks.java | 64 ++++++++--- .../scm/plugin/DefaultPluginManager.java | 10 +- .../sonia/scm/plugin/PluginCenterDto.java | 14 +-- .../scm/plugin/PluginCenterDtoMapper.java | 4 - .../api/v2/resources/ResourceLinksMock.java | 6 +- 12 files changed, 210 insertions(+), 107 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java rename scm-webapp/src/main/java/sonia/scm/api/v2/resources/{PluginResource.java => InstalledPluginResource.java} (75%) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 74cca02d92..de0a3ca1e9 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -282,16 +282,6 @@ public class PluginInformation return version; } - /** - * Method description - * - * - * @return - */ - public Map getLinks() { - return links; - } - /** * Method description * @@ -384,17 +374,6 @@ public class PluginInformation this.version = version; } - - /** - * Method description - * - * - * @param links - */ - public void setLinks(Map links) { - this.links = links; - } - //~--- fields --------------------------------------------------------------- /** Field description */ @@ -418,7 +397,4 @@ public class PluginInformation /** Field description */ private String version; - /** Field description */ - private Map links; - } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java new file mode 100644 index 0000000000..0f4864d2c0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -0,0 +1,106 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.plugin.PluginManager; +import sonia.scm.plugin.PluginPermissions; +import sonia.scm.plugin.PluginState; +import sonia.scm.plugin.PluginWrapper; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +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; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class AvailablePluginResource { + + private final PluginDtoCollectionMapper collectionMapper; + private PluginDtoMapper dtoMapper; + private final PluginManager pluginManager; + + @Inject + public AvailablePluginResource(PluginDtoCollectionMapper collectionMapper, PluginDtoMapper dtoMapper, PluginManager pluginManager) { + this.collectionMapper = collectionMapper; + this.dtoMapper = dtoMapper; + this.pluginManager = pluginManager; + } + + /** + * Returns a collection of available plugins. + * + * @return collection of available plugins. + */ + @GET + @Path("") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(CollectionDto.class) + @Produces(VndMediaType.PLUGIN_COLLECTION) + public Response getAvailablePlugins() { + PluginPermissions.read().check(); + Collection plugins = pluginManager.getAvailable() + .stream() + .filter(plugin -> plugin.getState().equals(PluginState.AVAILABLE)) + .collect(Collectors.toList()); + return Response.ok(collectionMapper.map(plugins)).build(); + } + + /** + * Returns available plugin. + * + * @return available plugin. + */ + @GET + @Path("/{name}/{version}") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(CollectionDto.class) + @Produces(VndMediaType.PLUGIN) + public Response getAvailablePlugin(@PathParam("name") String name, @PathParam("version") String version) { + PluginPermissions.read().check(); + Optional plugin = pluginManager.getAvailable() + .stream() + .filter(p -> p.getId().equals(name + ":" + version)) + .findFirst(); + return Response.ok(dtoMapper.map(plugin.get())).build(); + } + + /** + * Returns 200 when plugin installation is successful triggered. + * + * @return HTTP Status. + */ + @POST + @Path("/{name}/{version}/install") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(CollectionDto.class) + @Produces(VndMediaType.PLUGIN) + public Response installPlugin(@PathParam("name") String name, @PathParam("version") String version) { + PluginPermissions.manage().check(); + pluginManager.install(name + ":" + version); + return Response.ok().build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index c7b52861dc..906ff66759 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -42,7 +42,8 @@ public class IndexDtoGenerator extends HalAppenderMapper { link("logout", resourceLinks.authentication().logout()) ); if (PluginPermissions.read().isPermitted()) { - builder.single(link("plugins", resourceLinks.pluginCollection().self())); + builder.single(link("installedPlugins", resourceLinks.installedPluginCollection().self())); + builder.single(link("availablePlugins", resourceLinks.availablePluginCollection().self())); } if (UserPermissions.list().isPermitted()) { builder.single(link("users", resourceLinks.userCollection().self())); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java similarity index 75% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index be57e3f674..246bbf379f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -15,6 +15,7 @@ import sonia.scm.web.VndMediaType; import javax.inject.Inject; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; @@ -28,7 +29,7 @@ import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; -public class PluginResource { +public class InstalledPluginResource { private final PluginLoader pluginLoader; private final PluginDtoCollectionMapper collectionMapper; @@ -36,7 +37,7 @@ public class PluginResource { private final PluginManager pluginManager; @Inject - public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper, PluginManager pluginManager) { + public InstalledPluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper, PluginManager pluginManager) { this.pluginLoader = pluginLoader; this.collectionMapper = collectionMapper; this.mapper = mapper; @@ -70,7 +71,7 @@ public class PluginResource { * @return installed plugin with specified id */ @GET - @Path("{id}") + @Path("/{id}") @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 404, condition = "not found"), @@ -91,27 +92,4 @@ public class PluginResource { throw notFound(entity(Plugin.class, id)); } } - - /** - * Returns a collection of available plugins. - * - * @return collection of available plugins. - */ - @GET - @Path("/available") - @StatusCodes({ - @ResponseCode(code = 200, condition = "success"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @TypeHint(CollectionDto.class) - @Produces(VndMediaType.PLUGIN_COLLECTION) - public Response getAvailablePlugins() { - PluginPermissions.read().check(); - Collection plugins = pluginManager.getAvailable() - .stream() - .filter(plugin -> plugin.getState().equals(PluginState.AVAILABLE)) - .collect(Collectors.toList()); - return Response.ok(collectionMapper.map(plugins)).build(); - } - } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index 0bb5bd3610..5d8746c211 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -27,16 +27,24 @@ public class PluginDtoCollectionMapper { public HalRepresentation map(List plugins) { List dtos = plugins.stream().map(mapper::map).collect(toList()); - return new HalRepresentation(createLinks(), embedDtos(dtos)); + return new HalRepresentation(createInstalledPluginsLinks(), embedDtos(dtos)); } public HalRepresentation map(Collection plugins) { List dtos = plugins.stream().map(mapper::map).collect(toList()); - return new HalRepresentation(createLinks(), embedDtos(dtos)); + return new HalRepresentation(createAvailablePluginsLinks(), embedDtos(dtos)); } - private Links createLinks() { - String baseUrl = resourceLinks.pluginCollection().self(); + private Links createInstalledPluginsLinks() { + String baseUrl = resourceLinks.installedPluginCollection().self(); + + Links.Builder linksBuilder = linkingTo() + .with(Links.linkingTo().self(baseUrl).build()); + return linksBuilder.build(); + } + + private Links createAvailablePluginsLinks() { + String baseUrl = resourceLinks.availablePluginCollection().self(); Links.Builder linksBuilder = linkingTo() .with(Links.linkingTo().self(baseUrl).build()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 4886138a6b..9604ccbcc0 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -2,11 +2,11 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginState; import sonia.scm.plugin.PluginWrapper; import javax.inject.Inject; -import java.util.Map; - +import static de.otto.edison.hal.Link.*; import static de.otto.edison.hal.Links.linkingTo; public class PluginDtoMapper { @@ -23,13 +23,18 @@ public class PluginDtoMapper { } public PluginDto map(PluginInformation pluginInformation) { - Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.plugin() - .self(pluginInformation.getName())); + Links.Builder linksBuilder; + if (pluginInformation.getState() != null && pluginInformation.getState().equals(PluginState.AVAILABLE)) { + linksBuilder = linkingTo() + .self(resourceLinks.availablePlugin() + .self(pluginInformation.getName(), pluginInformation.getVersion())); - for (Object link : pluginInformation.getLinks().values()) { - System.out.println("Link is = " + link.toString()); - linksBuilder.item(((Map) link).values().iterator().next().toString()); + linksBuilder.single(link("install", resourceLinks.availablePlugin().install(pluginInformation.getName(), pluginInformation.getVersion()))); + } + else { + linksBuilder = linkingTo() + .self(resourceLinks.installedPlugin() + .self(pluginInformation.getName())); } PluginDto pluginDto = new PluginDto(linksBuilder.build()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java index e9b0f0a997..79c46369a3 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java @@ -4,18 +4,23 @@ import javax.inject.Inject; import javax.inject.Provider; import javax.ws.rs.Path; -@Path("v2/") +@Path("v2/plugins") public class PluginRootResource { - private Provider pluginResourceProvider; + private Provider installedPluginResourceProvider; + private Provider availablePluginResourceProvider; @Inject - public PluginRootResource(Provider pluginResourceProvider) { - this.pluginResourceProvider = pluginResourceProvider; + public PluginRootResource(Provider installedPluginResourceProvider, Provider availablePluginResourceProvider) { + this.installedPluginResourceProvider = installedPluginResourceProvider; + this.availablePluginResourceProvider = availablePluginResourceProvider; } - @Path("plugins") - public PluginResource plugins() { - return pluginResourceProvider.get(); + @Path("/installed") + public InstalledPluginResource installedPlugins() { + return installedPluginResourceProvider.get(); } + + @Path("/available") + public AvailablePluginResource availablePlugins() { return availablePluginResourceProvider.get(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 1d06659649..268f5f8619 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -651,35 +651,71 @@ class ResourceLinks { } } - public PluginLinks plugin() { - return new PluginLinks(scmPathInfoStore.get()); + public InstalledPluginLinks installedPlugin() { + return new InstalledPluginLinks(scmPathInfoStore.get()); } - static class PluginLinks { - private final LinkBuilder pluginLinkBuilder; + static class InstalledPluginLinks { + private final LinkBuilder installedPluginLinkBuilder; - PluginLinks(ScmPathInfo pathInfo) { - pluginLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginResource.class); + InstalledPluginLinks(ScmPathInfo pathInfo) { + installedPluginLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, InstalledPluginResource.class); } String self(String id) { - return pluginLinkBuilder.method("plugins").parameters().method("getInstalledPlugin").parameters(id).href(); + return installedPluginLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugin").parameters(id).href(); } } - public PluginCollectionLinks pluginCollection() { - return new PluginCollectionLinks(scmPathInfoStore.get()); + public InstalledPluginCollectionLinks installedPluginCollection() { + return new InstalledPluginCollectionLinks(scmPathInfoStore.get()); } - static class PluginCollectionLinks { - private final LinkBuilder pluginCollectionLinkBuilder; + static class InstalledPluginCollectionLinks { + private final LinkBuilder installedPluginCollectionLinkBuilder; - PluginCollectionLinks(ScmPathInfo pathInfo) { - pluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginResource.class); + InstalledPluginCollectionLinks(ScmPathInfo pathInfo) { + installedPluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, InstalledPluginResource.class); } String self() { - return pluginCollectionLinkBuilder.method("plugins").parameters().method("getInstalledPlugins").parameters().href(); + return installedPluginCollectionLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugins").parameters().href(); + } + } + + public AvailablePluginLinks availablePlugin() { + return new AvailablePluginLinks(scmPathInfoStore.get()); + } + + static class AvailablePluginLinks { + private final LinkBuilder availablePluginLinkBuilder; + + AvailablePluginLinks(ScmPathInfo pathInfo) { + availablePluginLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class); + } + + String self(String name, String version) { + return availablePluginLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugin").parameters(name, version).href(); + } + + String install(String name, String version) { + return availablePluginLinkBuilder.method("availablePlugins").parameters().method("installPlugin").parameters(name, version).href(); + } + } + + public AvailablePluginCollectionLinks availablePluginCollection() { + return new AvailablePluginCollectionLinks(scmPathInfoStore.get()); + } + + static class AvailablePluginCollectionLinks { + private final LinkBuilder availablePluginCollectionLinkBuilder; + + AvailablePluginCollectionLinks(ScmPathInfo pathInfo) { + availablePluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class); + } + + String self() { + return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugins").parameters().href(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 93e2e9aaa1..b48113e66d 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -186,8 +186,6 @@ public class DefaultPluginManager implements PluginManager PluginCenter center = getPluginCenter(); - // pluginHandler.install(id); - for (PluginInformation plugin : center.getPlugins()) { String pluginId = plugin.getId(); @@ -593,10 +591,10 @@ public class DefaultPluginManager implements PluginManager */ private PluginCenter getPluginCenter() { - PluginCenter center = null; // cache.get(PluginCenter.class.getName()); + PluginCenter center = cache.get(PluginCenter.class.getName()); -// if (center == null) -// { + if (center == null) + { synchronized (DefaultPluginManager.class) { String pluginUrl = configuration.getPluginUrl(); @@ -636,7 +634,7 @@ public class DefaultPluginManager implements PluginManager } } } -// } + } return center; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index f15c646c03..c7ed6ec3c4 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -57,7 +57,7 @@ public final class PluginCenterDto implements Serializable { private Dependency dependencies; @XmlElement(name = "_links") - private Map links; + private Map links; } @XmlAccessorType(XmlAccessType.FIELD) @@ -77,18 +77,10 @@ public final class PluginCenterDto implements Serializable { private String name; } - @XmlAccessorType(XmlAccessType.FIELD) - @XmlRootElement(name = "_links") - @Getter - static class Links { - private Link link; - private boolean templated; - } - @XmlAccessorType(XmlAccessType.FIELD) @Getter static class Link { - private String url; + private String href; + private boolean templated; } - } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java index cca5da6520..8e8520f50e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -24,10 +24,6 @@ public class PluginCenterDtoMapper { pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), Collections.singletonList(condition.getOs()), condition.getArch())); } - if (plugin.getLinks() != null) { - pluginInformation.setLinks(plugin.getLinks()); - } - pluginInformationSet.add(pluginInformation); } return pluginInformationSet; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 539b5c8d99..1aef4e57cb 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -36,8 +36,10 @@ public class ResourceLinksMock { when(resourceLinks.modifications()).thenReturn(new ResourceLinks.ModificationsLinks(uriInfo)); when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo)); when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo)); - when(resourceLinks.pluginCollection()).thenReturn(new ResourceLinks.PluginCollectionLinks(uriInfo)); - when(resourceLinks.plugin()).thenReturn(new ResourceLinks.PluginLinks(uriInfo)); + when(resourceLinks.installedPluginCollection()).thenReturn(new ResourceLinks.InstalledPluginCollectionLinks(uriInfo)); + when(resourceLinks.availablePluginCollection()).thenReturn(new ResourceLinks.AvailablePluginCollectionLinks(uriInfo)); + when(resourceLinks.installedPlugin()).thenReturn(new ResourceLinks.InstalledPluginLinks(uriInfo)); + when(resourceLinks.availablePlugin()).thenReturn(new ResourceLinks.AvailablePluginLinks(uriInfo)); when(resourceLinks.uiPluginCollection()).thenReturn(new ResourceLinks.UIPluginCollectionLinks(uriInfo)); when(resourceLinks.uiPlugin()).thenReturn(new ResourceLinks.UIPluginLinks(uriInfo)); when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(uriInfo)); From 8a8942cbc4731c7828b42bf5a67e87056f124c72 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 31 Jul 2019 10:43:39 +0200 Subject: [PATCH 019/135] cleanup / fix endpoints --- .../v2/resources/AvailablePluginResource.java | 19 +++++++++---------- .../v2/resources/InstalledPluginResource.java | 16 +++++----------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 0f4864d2c0..7899c9a9a7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -5,11 +5,9 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginInformation; -import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; import sonia.scm.plugin.PluginState; -import sonia.scm.plugin.PluginWrapper; import sonia.scm.web.VndMediaType; import javax.inject.Inject; @@ -19,9 +17,7 @@ import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -74,7 +70,7 @@ public class AvailablePluginResource { @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") }) - @TypeHint(CollectionDto.class) + @TypeHint(PluginDto.class) @Produces(VndMediaType.PLUGIN) public Response getAvailablePlugin(@PathParam("name") String name, @PathParam("version") String version) { PluginPermissions.read().check(); @@ -82,12 +78,17 @@ public class AvailablePluginResource { .stream() .filter(p -> p.getId().equals(name + ":" + version)) .findFirst(); - return Response.ok(dtoMapper.map(plugin.get())).build(); + if (plugin.isPresent()) { + return Response.ok(plugin.get()).build(); + } else { + throw notFound(entity(Plugin.class, name)); + } } /** - * Returns 200 when plugin installation is successful triggered. - * + * Triggers plugin installation. + * @param name plugin artefact name + * @param version plugin version * @return HTTP Status. */ @POST @@ -96,8 +97,6 @@ public class AvailablePluginResource { @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") }) - @TypeHint(CollectionDto.class) - @Produces(VndMediaType.PLUGIN) public Response installPlugin(@PathParam("name") String name, @PathParam("version") String version) { PluginPermissions.manage().check(); pluginManager.install(name + ":" + version); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index 246bbf379f..f10912e5ac 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -4,27 +4,21 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import sonia.scm.plugin.Plugin; -import sonia.scm.plugin.PluginCenter; -import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; -import sonia.scm.plugin.PluginState; import sonia.scm.plugin.PluginWrapper; import sonia.scm.web.VndMediaType; import javax.inject.Inject; import javax.ws.rs.GET; -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; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -66,12 +60,12 @@ public class InstalledPluginResource { /** * Returns the installed plugin with the given id. * - * @param id id of plugin + * @param name name of plugin * * @return installed plugin with specified id */ @GET - @Path("/{id}") + @Path("/{name}") @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 404, condition = "not found"), @@ -79,17 +73,17 @@ public class InstalledPluginResource { }) @TypeHint(PluginDto.class) @Produces(VndMediaType.PLUGIN) - public Response getInstalledPlugin(@PathParam("id") String id) { + public Response getInstalledPlugin(@PathParam("name") String name) { PluginPermissions.read().check(); Optional pluginDto = pluginLoader.getInstalledPlugins() .stream() - .filter(plugin -> id.equals(plugin.getPlugin().getInformation().getName(false))) + .filter(plugin -> name.equals(plugin.getPlugin().getInformation().getName())) .map(mapper::map) .findFirst(); if (pluginDto.isPresent()) { return Response.ok(pluginDto.get()).build(); } else { - throw notFound(entity(Plugin.class, id)); + throw notFound(entity(Plugin.class, name)); } } } From 00fa943e51f574597149be607a60a7bb55dbd01f Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 31 Jul 2019 13:24:31 +0200 Subject: [PATCH 020/135] fix SonarQube issues --- .../scm/repository/spi/GitDiffCommand.java | 82 ++++--------------- .../repository/spi/GitDiffResultCommand.java | 12 ++- 2 files changed, 26 insertions(+), 68 deletions(-) diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java index 7d5e45a5c2..5d5f27806b 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,9 +24,8 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ @@ -36,84 +35,39 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffFormatter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; import java.io.BufferedOutputStream; +import java.io.IOException; import java.io.OutputStream; /** * * @author Sebastian Sdorra */ -public class GitDiffCommand extends AbstractGitCommand implements DiffCommand -{ +public class GitDiffCommand extends AbstractGitCommand implements DiffCommand { - /** - * the logger for GitDiffCommand - */ - private static final Logger logger = - LoggerFactory.getLogger(GitDiffCommand.class); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * - * @param context - * @param repository - */ - GitDiffCommand(GitContext context, Repository repository) - { + GitDiffCommand(GitContext context, Repository repository) { super(context, repository); } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param request - * @param output - */ @Override - public void getDiffResult(DiffCommandRequest request, OutputStream output) - { - DiffFormatter formatter = null; - - try - { - org.eclipse.jgit.lib.Repository repository = open(); - - formatter = new DiffFormatter(new BufferedOutputStream(output)); + public void getDiffResult(DiffCommandRequest request, OutputStream output) throws IOException { + @SuppressWarnings("squid:S2095") // repository will be closed with the RepositoryService + org.eclipse.jgit.lib.Repository repository = open(); + try (DiffFormatter formatter = new DiffFormatter(new BufferedOutputStream(output))) { formatter.setRepository(repository); Differ.Diff diff = Differ.diff(repository, request); - for (DiffEntry e : diff.getEntries()) - { - if (!e.getOldId().equals(e.getNewId())) - { + for (DiffEntry e : diff.getEntries()) { + if (!e.getOldId().equals(e.getNewId())) { formatter.format(e); } } formatter.flush(); } - catch (Exception ex) - { - // TODO throw exception - logger.error("could not create diff", ex); - } - finally - { - GitUtil.release(formatter); - } } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java index 0d5f4f7b9e..a3f63b8f5a 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java @@ -4,6 +4,7 @@ import com.google.common.base.Throwables; import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffFormatter; import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import sonia.scm.repository.api.DiffFile; import sonia.scm.repository.api.DiffResult; @@ -47,7 +48,11 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu @Override public Iterator iterator() { - return diff.getEntries().stream().map(diffEntry -> new GitDiffFile(repository, diffEntry)).collect(Collectors.toList()).iterator(); + return diff.getEntries() + .stream() + .map(diffEntry -> new GitDiffFile(repository, diffEntry)) + .collect(Collectors.toList()) + .iterator(); } } @@ -89,13 +94,12 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu } private String format(org.eclipse.jgit.lib.Repository repository, DiffEntry entry) { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - DiffFormatter formatter = new DiffFormatter(baos); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); DiffFormatter formatter = new DiffFormatter(baos)) { formatter.setRepository(repository); formatter.format(entry); return baos.toString(); } catch (IOException ex) { - throw Throwables.propagate(ex); + throw new InternalRepositoryException(GitDiffResultCommand.this.repository, "failed to format diff entry", ex); } } From f6fd25cbc383bf85505d052bddbd3a26f78acd77 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 31 Jul 2019 13:26:58 +0200 Subject: [PATCH 021/135] reactivate jenkins lifecycle stage --- Jenkinsfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 0f586bf346..e817ac2ba1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -51,9 +51,9 @@ node('docker') { if (isMainBranch()) { -// stage('Lifecycle') { -// nexusPolicyEvaluation iqApplication: selectedApplication('scm'), iqScanPatterns: [[scanPattern: 'scm-server/target/scm-server-app.zip']], iqStage: 'build' -// } + stage('Lifecycle') { + nexusPolicyEvaluation iqApplication: selectedApplication('scm'), iqScanPatterns: [[scanPattern: 'scm-server/target/scm-server-app.zip']], iqStage: 'build' + } stage('Archive') { archiveArtifacts 'scm-webapp/target/scm-webapp.war' From f10b653a1d2318ae23979df436c3242e2c30f07b Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 31 Jul 2019 13:37:12 +0200 Subject: [PATCH 022/135] Fix thrown exceptions --- .../sonia/scm/repository/spi/GitDiffCommandTest.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java index f6e462f968..fd9c45be5c 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java @@ -3,6 +3,7 @@ package sonia.scm.repository.spi; import org.junit.Test; import java.io.ByteArrayOutputStream; +import java.io.IOException; import static org.junit.Assert.assertEquals; @@ -38,7 +39,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { "+f\n"; @Test - public void diffForOneRevisionShouldCreateDiff() { + public void diffForOneRevisionShouldCreateDiff() throws IOException { GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository); DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); @@ -48,7 +49,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { } @Test - public void diffForOneBranchShouldCreateDiff() { + public void diffForOneBranchShouldCreateDiff() throws IOException { GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository); DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("test-branch"); @@ -58,7 +59,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { } @Test - public void diffForPathShouldCreateLimitedDiff() { + public void diffForPathShouldCreateLimitedDiff() throws IOException { GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository); DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("test-branch"); @@ -69,7 +70,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { } @Test - public void diffBetweenTwoBranchesShouldCreateDiff() { + public void diffBetweenTwoBranchesShouldCreateDiff() throws IOException { GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository); DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("master"); @@ -80,7 +81,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { } @Test - public void diffBetweenTwoBranchesForPathShouldCreateLimitedDiff() { + public void diffBetweenTwoBranchesForPathShouldCreateLimitedDiff() throws IOException { GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository); DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("master"); From 508e5bbf3bba80a519719335e36b4327f0fa8ccb Mon Sep 17 00:00:00 2001 From: Florian Scholdei Date: Wed, 31 Jul 2019 15:40:35 +0200 Subject: [PATCH 023/135] implemented ui for available plugins page --- .../packages/ui-components/src/CardColumn.js | 38 ++++++- scm-ui/src/admin/containers/Admin.js | 106 ++++++++++++------ .../admin/plugins/components/PluginEntry.js | 27 ++++- .../plugins/containers/PluginsOverview.js | 37 +++++- scm-ui/src/modules/indexResource.js | 8 +- 5 files changed, 165 insertions(+), 51 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/CardColumn.js b/scm-ui-components/packages/ui-components/src/CardColumn.js index e1eb65255a..65710c7be2 100644 --- a/scm-ui-components/packages/ui-components/src/CardColumn.js +++ b/scm-ui-components/packages/ui-components/src/CardColumn.js @@ -25,12 +25,17 @@ const styles = { }, content: { display: "flex", - flexGrow: 1 + flexGrow: 1, + alignItems: "center", + justifyContent: "space-between" }, footer: { display: "flex", marginTop: "auto", paddingBottom: "1.5rem" + }, + noBottomMargin: { + marginBottom: "0 !important" } }; @@ -38,9 +43,11 @@ type Props = { title: string, description: string, avatar: React.Node, + contentRight?: React.Node, footerLeft: React.Node, footerRight: React.Node, link: string, + // context props classes: any }; @@ -55,7 +62,15 @@ class CardColumn extends React.Component { }; render() { - const { avatar, title, description, footerLeft, footerRight, classes } = this.props; + const { + avatar, + title, + description, + contentRight, + footerLeft, + footerRight, + classes + } = this.props; const link = this.createLink(); return ( <> @@ -64,16 +79,29 @@ class CardColumn extends React.Component {

{avatar}
-
+
-
+

{title}

{description}

+ {contentRight && contentRight}
-
+
{footerLeft}
{footerRight}
diff --git a/scm-ui/src/admin/containers/Admin.js b/scm-ui/src/admin/containers/Admin.js index 58e37a0a7c..22c6184548 100644 --- a/scm-ui/src/admin/containers/Admin.js +++ b/scm-ui/src/admin/containers/Admin.js @@ -1,14 +1,24 @@ // @flow import React from "react"; import { translate } from "react-i18next"; -import {Redirect, Route, Switch} from "react-router-dom"; +import { Redirect, Route, Switch } from "react-router-dom"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import type { History } from "history"; import { connect } from "react-redux"; import { compose } from "redux"; import type { Links } from "@scm-manager/ui-types"; -import { Page, Navigation, NavLink, Section, SubNavigation } from "@scm-manager/ui-components"; -import { getLinks } from "../../modules/indexResource"; +import { + Page, + Navigation, + NavLink, + Section, + SubNavigation +} from "@scm-manager/ui-components"; +import { + getLinks, + getAvailablePluginsLink, + getInstalledPluginsLink +} from "../../modules/indexResource"; import AdminDetails from "./AdminDetails"; import PluginsOverview from "../plugins/containers/PluginsOverview"; import GlobalConfig from "./GlobalConfig"; @@ -18,6 +28,8 @@ import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole"; type Props = { links: Links, + availablePluginsLink: string, + installedPluginsLink: string, // context objects t: string => string, @@ -28,7 +40,7 @@ type Props = { class Admin extends React.Component { stripEndingSlash = (url: string) => { if (url.endsWith("/")) { - if(url.includes("role")) { + if (url.includes("role")) { return url.substring(0, url.length - 2); } return url.substring(0, url.length - 1); @@ -47,7 +59,7 @@ class Admin extends React.Component { }; render() { - const { links, t } = this.props; + const { links, availablePluginsLink, installedPluginsLink, t } = this.props; const url = this.matchedUrl(); const extensionProps = { @@ -62,34 +74,54 @@ class Admin extends React.Component { - - + + ( - + )} /> ( - + )} /> ( - + )} /> ( - + )} /> { ( - + )} /> ( - - )} + render={() => } /> { icon="fas fa-info-circle" label={t("admin.menu.informationNavLink")} /> - { - links.plugins && - - - {/* Activate this again after available plugins page is created */} - {/**/} - - } + {(availablePluginsLink || installedPluginsLink) && ( + + {installedPluginsLink && ( + + )} + {availablePluginsLink && ( + + )} + + )} { const mapStateToProps = (state: any) => { const links = getLinks(state); + const availablePluginsLink = getAvailablePluginsLink(state); + const installedPluginsLink = getInstalledPluginsLink(state); return { - links + links, + availablePluginsLink, + installedPluginsLink }; }; diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index a8cdaad915..3693c2e284 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -1,11 +1,21 @@ //@flow import React from "react"; +import injectSheet from "react-jss"; import type { Plugin } from "@scm-manager/ui-types"; import { CardColumn } from "@scm-manager/ui-components"; import PluginAvatar from "./PluginAvatar"; type Props = { - plugin: Plugin + plugin: Plugin, + + // context props + classes: any +}; + +const styles = { + link: { + pointerEvents: "all" + } }; class PluginEntry extends React.Component { @@ -13,6 +23,17 @@ class PluginEntry extends React.Component { return ; }; + createContentRight = (plugin: Plugin) => { + const { classes } = this.props; + if (plugin._links && plugin._links.install && plugin._links.install.href) { + return ( + + + + ); + } + }; + createFooterLeft = (plugin: Plugin) => { return {plugin.author}; }; @@ -24,6 +45,7 @@ class PluginEntry extends React.Component { render() { const { plugin } = this.props; const avatar = this.createAvatar(plugin); + const contentRight = this.createContentRight(plugin); const footerLeft = this.createFooterLeft(plugin); const footerRight = this.createFooterRight(plugin); @@ -34,6 +56,7 @@ class PluginEntry extends React.Component { avatar={avatar} title={plugin.name} description={plugin.description} + contentRight={contentRight} footerLeft={footerLeft} footerRight={footerRight} /> @@ -41,4 +64,4 @@ class PluginEntry extends React.Component { } } -export default PluginEntry; +export default injectSheet(styles)(PluginEntry); diff --git a/scm-ui/src/admin/plugins/containers/PluginsOverview.js b/scm-ui/src/admin/plugins/containers/PluginsOverview.js index 7a3fc7ec4a..5a8a46f884 100644 --- a/scm-ui/src/admin/plugins/containers/PluginsOverview.js +++ b/scm-ui/src/admin/plugins/containers/PluginsOverview.js @@ -18,7 +18,10 @@ import { isFetchPluginsPending } from "../modules/plugins"; import PluginsList from "../components/PluginsList"; -import { getPluginsLink } from "../../../modules/indexResource"; +import { + getAvailablePluginsLink, + getInstalledPluginsLink +} from "../../../modules/indexResource"; type Props = { loading: boolean, @@ -26,7 +29,8 @@ type Props = { collection: PluginCollection, baseUrl: string, installed: boolean, - pluginsLink: string, + availablePluginsLink: string, + installedPluginsLink: string, // context objects t: string => string, @@ -37,8 +41,27 @@ type Props = { class PluginsOverview extends React.Component { componentDidMount() { - const { fetchPluginsByLink, pluginsLink } = this.props; - fetchPluginsByLink(pluginsLink); + const { + installed, + fetchPluginsByLink, + availablePluginsLink, + installedPluginsLink + } = this.props; + fetchPluginsByLink(installed ? installedPluginsLink : availablePluginsLink); + } + + componentDidUpdate(prevProps) { + const { + installed, + fetchPluginsByLink, + availablePluginsLink, + installedPluginsLink + } = this.props; + if (prevProps.installed !== installed) { + fetchPluginsByLink( + installed ? installedPluginsLink : availablePluginsLink + ); + } } render() { @@ -81,13 +104,15 @@ const mapStateToProps = state => { const collection = getPluginCollection(state); const loading = isFetchPluginsPending(state); const error = getFetchPluginsFailure(state); - const pluginsLink = getPluginsLink(state); + const availablePluginsLink = getAvailablePluginsLink(state); + const installedPluginsLink = getInstalledPluginsLink(state); return { collection, loading, error, - pluginsLink + availablePluginsLink, + installedPluginsLink }; }; diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js index 9bfa620674..aa8ebad5a2 100644 --- a/scm-ui/src/modules/indexResource.js +++ b/scm-ui/src/modules/indexResource.js @@ -116,8 +116,12 @@ export function getUiPluginsLink(state: Object) { return getLink(state, "uiPlugins"); } -export function getPluginsLink(state: Object) { - return getLink(state, "plugins"); +export function getAvailablePluginsLink(state: Object) { + return getLink(state, "availablePlugins"); +} + +export function getInstalledPluginsLink(state: Object) { + return getLink(state, "installedPlugins"); } export function getMeLink(state: Object) { From c2e7ecd164a6d82a5ca4bdfc5e0cf8b79e894f31 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 31 Jul 2019 16:59:13 +0200 Subject: [PATCH 024/135] add tests --- .../v2/resources/AvailablePluginResource.java | 4 +- .../v2/resources}/PluginCenterDto.java | 13 +- .../v2/resources}/PluginCenterDtoMapper.java | 5 +- .../scm/plugin/DefaultPluginManager.java | 6 +- .../InstalledPluginResourceTest.java | 124 +++++++++++ .../resources/PluginCenterDtoMapperTest.java | 91 ++++++++ .../scm/api/v2/installedPlugins-001.json | 201 ++++++++++++++++++ .../sonia/scm/plugin/plugincenter-001.json | 72 +++++++ 8 files changed, 504 insertions(+), 12 deletions(-) rename scm-webapp/src/main/java/sonia/scm/{plugin => api/v2/resources}/PluginCenterDto.java (87%) rename scm-webapp/src/main/java/sonia/scm/{plugin => api/v2/resources}/PluginCenterDtoMapper.java (89%) create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 7899c9a9a7..ca8f3eba29 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -27,13 +27,11 @@ import static sonia.scm.NotFoundException.notFound; public class AvailablePluginResource { private final PluginDtoCollectionMapper collectionMapper; - private PluginDtoMapper dtoMapper; private final PluginManager pluginManager; @Inject - public AvailablePluginResource(PluginDtoCollectionMapper collectionMapper, PluginDtoMapper dtoMapper, PluginManager pluginManager) { + public AvailablePluginResource(PluginDtoCollectionMapper collectionMapper, PluginManager pluginManager) { this.collectionMapper = collectionMapper; - this.dtoMapper = dtoMapper; this.pluginManager = pluginManager; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java similarity index 87% rename from scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java index c7ed6ec3c4..2b72e7fddc 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java @@ -1,7 +1,9 @@ -package sonia.scm.plugin; +package sonia.scm.api.v2.resources; import com.google.common.collect.ImmutableList; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -24,7 +26,7 @@ public final class PluginCenterDto implements Serializable { @XmlRootElement(name = "_embedded") @XmlAccessorType(XmlAccessType.FIELD) - static class Embedded { + public static class Embedded { @XmlElement(name = "plugins") private List plugins; @@ -40,7 +42,8 @@ public final class PluginCenterDto implements Serializable { @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "plugins") @Getter - static class Plugin { + @AllArgsConstructor + public static class Plugin { private String name; private String displayName; @@ -63,7 +66,8 @@ public final class PluginCenterDto implements Serializable { @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "conditions") @Getter - static class Condition { + @AllArgsConstructor + public static class Condition { private String os; private String arch; @@ -73,6 +77,7 @@ public final class PluginCenterDto implements Serializable { @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "dependencies") @Getter + @AllArgsConstructor static class Dependency { private String name; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java similarity index 89% rename from scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java index 8e8520f50e..4ec5a48667 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java @@ -1,4 +1,7 @@ -package sonia.scm.plugin; +package sonia.scm.api.v2.resources; + +import sonia.scm.plugin.PluginCondition; +import sonia.scm.plugin.PluginInformation; import java.util.Collections; import java.util.HashSet; diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index b48113e66d..d17a693969 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -46,6 +46,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.SCMContextProvider; +import sonia.scm.api.v2.resources.PluginCenterDto; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.config.ScmConfiguration; @@ -67,21 +68,18 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import java.util.Set; -import java.util.stream.Collectors; import javax.xml.bind.JAXB; import sonia.scm.net.ahc.AdvancedHttpClient; -import static sonia.scm.plugin.PluginCenterDtoMapper.*; +import static sonia.scm.api.v2.resources.PluginCenterDtoMapper.*; /** * TODO replace aether stuff. diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java new file mode 100644 index 0000000000..ffd480b530 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -0,0 +1,124 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.io.Resources; +import de.otto.edison.hal.HalRepresentation; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockDispatcherFactory; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; +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.plugin.Plugin; +import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.plugin.PluginManager; +import sonia.scm.plugin.PluginState; +import sonia.scm.plugin.PluginWrapper; +import sonia.scm.web.VndMediaType; + +import javax.inject.Provider; +import javax.servlet.http.HttpServletResponse; +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InstalledPluginResourceTest { + + private Dispatcher dispatcher; + private URL resources = Resources.getResource("sonia/scm/api/v2/installedPlugins-001.json"); + + @Mock + Provider installedPluginResourceProvider; + + @Mock + Provider availablePluginResourceProvider; + + @Mock + private PluginManager pluginManager; + + @Mock + private PluginLoader pluginLoader; + + @Mock + private PluginDtoCollectionMapper collectionMapper; + + @Mock + private PluginDtoMapper mapper; + + @InjectMocks + InstalledPluginResource installedPluginResource; + + PluginRootResource pluginRootResource; + + private final Subject subject = mock(Subject.class); + + @BeforeEach + void prepareEnvironment() { + dispatcher = MockDispatcherFactory.createDispatcher(); + pluginRootResource = new PluginRootResource(installedPluginResourceProvider, availablePluginResourceProvider); + when(installedPluginResourceProvider.get()).thenReturn(installedPluginResource); + dispatcher.getRegistry().addSingletonResource(pluginRootResource); + } + + @Nested + class withAuthorization { + + @BeforeEach + void bindSubject() { + ThreadContext.bind(subject); + when(subject.isPermitted(any(String.class))).thenReturn(true); + } + + @AfterEach + public void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void getInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setVersion("2.0.0"); + pluginInformation.setName("plugin-name"); + pluginInformation.setState(PluginState.INSTALLED); + Plugin plugin = new Plugin(2, pluginInformation, null, null, false, null); + PluginWrapper pluginWrapper = new PluginWrapper(plugin, null, null, null); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(pluginWrapper)); + + PluginDto pluginDto = new PluginDto(); + pluginDto.setName("plugin-name"); + pluginDto.setVersion("2.0.0"); + //TODO How to mock this? + when(collectionMapper.map(Collections.singletonList(pluginWrapper))).thenReturn(new HalRepresentation()); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); + request.accept(VndMediaType.PLUGIN_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/plugins/installed\"}")); + } + + @Test + void getInstalledPlugin() { + } + + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java new file mode 100644 index 0000000000..ecbc44a1ed --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java @@ -0,0 +1,91 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sonia.scm.plugin.PluginInformation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static sonia.scm.api.v2.resources.PluginCenterDto.*; + +class PluginCenterDtoMapperTest { + + private PluginCenterDtoMapper pluginCenterDtoMapper; + + @BeforeEach + void initMapper() { + pluginCenterDtoMapper = new PluginCenterDtoMapper(); + } + + @Test + void shouldMapSinglePlugin() { + Plugin plugin = new Plugin( + "scm-hitchhiker-plugin", + "SCM Hitchhiker Plugin", + "plugin for hitchhikers", + "Travel", + "2.0.0", + "trillian", + "555000444", + new Condition("linux", "amd64","2.0.0"), + new Dependency("scm-review-plugin"), + new HashMap<>()); + + PluginInformation result = PluginCenterDtoMapper.map(Collections.singletonList(plugin)).iterator().next(); + + assertThat(result.getAuthor()).isEqualTo(plugin.getAuthor()); + assertThat(result.getCategory()).isEqualTo(plugin.getCategory()); + assertThat(result.getVersion()).isEqualTo(plugin.getVersion()); + assertThat(result.getCondition().getArch()).isEqualTo(plugin.getConditions().getArch()); + assertThat(result.getCondition().getMinVersion()).isEqualTo(plugin.getConditions().getMinVersion()); + assertThat(result.getCondition().getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs()); + assertThat(result.getDescription()).isEqualTo(plugin.getDescription()); + assertThat(result.getName()).isEqualTo(plugin.getName()); + } + + @Test + void shouldMapMultiplePlugins() { + Plugin plugin1 = new Plugin( + "scm-hitchhiker-plugin", + "SCM Hitchhiker Plugin", + "plugin for hitchhikers", + "Travel", + "2.0.0", + "dent", + "555000444", + new Condition("linux", "amd64","2.0.0"), + new Dependency("scm-review-plugin"), + new HashMap<>()); + + Plugin plugin2 = new Plugin( + "scm-review-plugin", + "SCM Hitchhiker Plugin", + "plugin for hitchhikers", + "Travel", + "2.1.0", + "trillian", + "12345678aa", + new Condition("linux", "amd64","2.0.0"), + new Dependency("scm-review-plugin"), + new HashMap<>()); + + Set resultSet = PluginCenterDtoMapper.map(Arrays.asList(plugin1, plugin2)); + + List pluginsList = new ArrayList(resultSet); + + PluginInformation pluginInformation1 = (PluginInformation) pluginsList.get(1); + PluginInformation pluginInformation2 = (PluginInformation) pluginsList.get(0); + + assertThat(pluginInformation1.getAuthor()).isEqualTo(plugin1.getAuthor()); + assertThat(pluginInformation1.getVersion()).isEqualTo(plugin1.getVersion()); + assertThat(pluginInformation2.getAuthor()).isEqualTo(plugin2.getAuthor()); + assertThat(pluginInformation2.getVersion()).isEqualTo(plugin2.getVersion()); + assertThat(resultSet.size()).isEqualTo(2); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json new file mode 100644 index 0000000000..077851a9fa --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json @@ -0,0 +1,201 @@ +{ + "_links": { + "self": { "href": "http://localhost:8081/scm/api/v2/plugins/installed" } + }, + "_embedded": { + "plugins": [ + { + "name": "scm-issuetracker-plugin", + "category": "Library Plugin", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Helper classes for issuetracker plugins.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-issuetracker-plugin" + } + } + }, + { + "name": "scm-webhook-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Notify a remote webserver whenever a repository is pushed to.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-webhook-plugin" + } + } + }, + { + "name": "scm-jenkins-plugin", + "category": "Continuous Integration", + "version": "2.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "This plugin will ping your Jenkins CI server when a new commit is pushed to SCM-Manager.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-jenkins-plugin" + } + } + }, + { + "name": "scm-legacy-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "The easiest way to share your Git, Mercurial\n and Subversion repositories over http.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-legacy-plugin" + } + } + }, + { + "name": "scm-jira-plugin", + "category": "Issue-Tracking", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "This plugin integrates Atlassian JIRA to SCM-Manager.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-jira-plugin" + } + } + }, + { + "name": "scm-ci-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Cloudogu GmbH", + "description": "SCM-Manager CI Plugin", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-ci-plugin" + } + } + }, + { + "name": "scm-svn-plugin", + "category": "Subversion", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Plugin for the version control system Subversion", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-svn-plugin" + } + } + }, + { + "name": "scm-hg-plugin", + "category": "Mercurial", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Plugin for the version control system Mercurial", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-hg-plugin" + } + } + }, + { + "name": "scm-review-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Cloudogu GmbH", + "description": "SCM-Manager Review Plugin", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-review-plugin" + } + } + }, + { + "name": "scm-git-plugin", + "category": "Git", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Plugin for the version control system Git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-git-plugin" + } + } + }, + { + "name": "scm-script-plugin", + "category": "Development", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Script support for scm-manager.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-script-plugin" + } + } + }, + { + "name": "scm-mail-plugin", + "category": "Library Plugin", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "The mail plugin provides an api for sending e-mails.\n This api can be used by other plugins.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-mail-plugin" + } + } + }, + { + "name": "scm-authormapping-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Lookup and transform usernames to the real names stored in the scm-manager user database or in a mapping table.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-authormapping-plugin" + } + } + }, + { + "name": "scm-pushlog-plugin", + "category": "Miscellaneous", + "version": "2.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "Tracks who pushed what to a repository.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-pushlog-plugin" + } + } + }, + { + "name": "scm-branchwp-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "This plugin adds branch write protection for repositories.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-branchwp-plugin" + } + } + }, + { + "name": "scm-notify-plugin", + "category": "Miscellaneous", + "version": "2.0.0-SNAPSHOT", + "author": "Sebastian Sdorra", + "description": "This plugin sends email notifications to a list of subscribed addresses whenever a repo has changes.", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-notify-plugin" + } + } + } + ] + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json b/scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json new file mode 100644 index 0000000000..d66f2f6c6b --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json @@ -0,0 +1,72 @@ +{ + "_embedded": { + "plugins": [ + { + "name": "scm-branchwp-plugin", + "displayName": "SCM Branch WritePermission Plugin", + "description": "This plugin adds branch write protection for repositories.", + "category": "Miscellaneous", + "version": "1.0.0", + "author": "Sebastian Sdorra", + "sha256sum": "f6585d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", + "_links": { + "download": { + "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-branchwp-plugin/job/2.0.0/lastSuccessfulBuild/artifact/target/scm-branchwp-plugin-2.0.0-SNAPSHOT.smp" + } + } + }, + { + "name": "scm-pathwp-plugin", + "displayName": "SCM Path WritePermission Plugin", + "description": "This plugin adds path write protection for repositories.", + "category": "Miscellaneous", + "version": "2.1.0", + "author": "Sebastian Sdorra", + "conditions": { + "os": "linux", + "minVersion": "2.1.0" + }, + "sha256sum": "abc85d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", + "_links": { + "download": { + "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-pathwp-plugin/job/2.0.0/lastSuccessfulBuild/artifact/target/scm-pathwp-plugin-2.0.0-SNAPSHOT.smp" + } + } + }, + { + "name": "scm-mail-plugin", + "displayName": "SCM Mail Plugin", + "description": "The mail plugin provides an api for sending e-mails. This api can be used by other plugins.", + "category": "Library Plugin", + "version": "2.6.7", + "author": "Sebastian Sdorra", + "conditions": { + "os": "windows" + }, + "sha256sum": "def85d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", + "_links": { + "download": { + "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-mail-plugin/job/2.0.0/lastSuccessfulBuild/artifact/target/scm-mail-plugin-2.0.0-SNAPSHOT.smp" + } + } + }, + { + "name": "scm-review-plugin", + "displayName": "SCM Review Plugin", + "description": "SCM-Manager Review Plugin", + "category": "Miscellaneous", + "version": "2.4.0", + "author": "Sebastian Sdorra", + "conditions": { + "arch": "armv7" + }, + "sha256sum": "12385d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", + "_links": { + "download": { + "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-review-plugin/job/develop/lastSuccessfulBuild/artifact/target/scm-review-plugin-2.0.0-SNAPSHOT.smp" + } + } + } + ] + } +} From f699f0ef432244ced6d565f385c4a85bd9f0fd2f Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 31 Jul 2019 17:11:28 +0200 Subject: [PATCH 025/135] fix Test --- .../v2/resources/InstalledPluginResourceTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index ffd480b530..acca905320 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -31,8 +31,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.Collections; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +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; @@ -103,8 +102,7 @@ class InstalledPluginResourceTest { PluginDto pluginDto = new PluginDto(); pluginDto.setName("plugin-name"); pluginDto.setVersion("2.0.0"); - //TODO How to mock this? - when(collectionMapper.map(Collections.singletonList(pluginWrapper))).thenReturn(new HalRepresentation()); + when(collectionMapper.map(Collections.singletonList(pluginWrapper))).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); request.accept(VndMediaType.PLUGIN_COLLECTION); @@ -112,13 +110,18 @@ class InstalledPluginResourceTest { dispatcher.invoke(request, response); - assertEquals(HttpServletResponse.SC_OK, response.getStatus()); - assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/plugins/installed\"}")); + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + assertThat(response.getContentAsString()).contains("\"marker\":\"x\""); } @Test void getInstalledPlugin() { } + public class MockedResultDto extends HalRepresentation { + public String getMarker() { + return "x"; + } + } } } From 6caf01f4f6db232c45eda4ae30854cd4cba77460 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 31 Jul 2019 17:14:34 +0200 Subject: [PATCH 026/135] close branch feature/parsed_diff From 35c5a6951149eb20d0e3714365e9b2bc8eda471b Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 08:38:53 +0200 Subject: [PATCH 027/135] add resource tests --- .../v2/resources/AvailablePluginResource.java | 3 + .../AvailablePluginResourceTest.java | 169 ++++++++++++++++++ .../InstalledPluginResourceTest.java | 72 +++++--- 3 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index ca8f3eba29..c3663b82b5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -11,6 +11,7 @@ import sonia.scm.plugin.PluginState; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -66,6 +67,7 @@ public class AvailablePluginResource { @Path("/{name}/{version}") @StatusCodes({ @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 404, condition = "not found"), @ResponseCode(code = 500, condition = "internal server error") }) @TypeHint(PluginDto.class) @@ -91,6 +93,7 @@ public class AvailablePluginResource { */ @POST @Path("/{name}/{version}/install") + @Consumes(VndMediaType.PLUGIN) @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java new file mode 100644 index 0000000000..1ec1c52e81 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -0,0 +1,169 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockDispatcherFactory; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.jboss.resteasy.spi.UnhandledException; +import org.junit.jupiter.api.AfterEach; +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.plugin.PluginInformation; +import sonia.scm.plugin.PluginManager; +import sonia.scm.plugin.PluginState; +import sonia.scm.web.VndMediaType; + +import javax.inject.Provider; +import javax.servlet.http.HttpServletResponse; + +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.util.Collections; + +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.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AvailablePluginResourceTest { + + private Dispatcher dispatcher; + + @Mock + Provider installedPluginResourceProvider; + + @Mock + Provider availablePluginResourceProvider; + + @Mock + private PluginDtoCollectionMapper collectionMapper; + + @Mock + private PluginManager pluginManager; + + @InjectMocks + AvailablePluginResource availablePluginResource; + + PluginRootResource pluginRootResource; + + private final Subject subject = mock(Subject.class); + + + @BeforeEach + void prepareEnvironment() { + dispatcher = MockDispatcherFactory.createDispatcher(); + pluginRootResource = new PluginRootResource(installedPluginResourceProvider, availablePluginResourceProvider); + when(availablePluginResourceProvider.get()).thenReturn(availablePluginResource); + dispatcher.getRegistry().addSingletonResource(pluginRootResource); + } + + @Nested + class withAuthorization { + + @BeforeEach + void bindSubject() { + ThreadContext.bind(subject); + when(subject.isPermitted(any(String.class))).thenReturn(true); + } + + @AfterEach + public void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void getAvailablePlugins() throws URISyntaxException, UnsupportedEncodingException { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setState(PluginState.AVAILABLE); + when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(pluginInformation)); + when(collectionMapper.map(Collections.singletonList(pluginInformation))).thenReturn(new MockedResultDto()); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); + request.accept(VndMediaType.PLUGIN_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + assertThat(response.getContentAsString()).contains("\"marker\":\"x\""); + } + + @Test + void getAvailablePlugin() throws UnsupportedEncodingException, URISyntaxException { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setState(PluginState.AVAILABLE); + pluginInformation.setName("pluginName"); + pluginInformation.setVersion("2.0.0"); + when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(pluginInformation)); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName/2.0.0"); + request.accept(VndMediaType.PLUGIN); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + assertThat(response.getContentAsString()).contains("\"name\":\"pluginName\""); + } + + @Test + void installPlugin() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/2.0.0/install"); + request.accept(VndMediaType.PLUGIN); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + verify(pluginManager).install("pluginName:2.0.0"); + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + } + } + + @Nested + class WithoutAuthorization { + + @Test + void shouldNotGetAvailablePluginsIfMissingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); + request.accept(VndMediaType.PLUGIN_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + } + + @Test + void shouldNotGetAvailablePluginIfMissingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName/2.0.0"); + request.accept(VndMediaType.PLUGIN); + MockHttpResponse response = new MockHttpResponse(); + + assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + } + + @Test + void shouldNotInstallPluginIfMissingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/2.0.0/install"); + request.accept(VndMediaType.PLUGIN); + MockHttpResponse response = new MockHttpResponse(); + + assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + } + } + + public class MockedResultDto extends HalRepresentation { + public String getMarker() { + return "x"; + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index acca905320..f6633b4965 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -1,6 +1,5 @@ package sonia.scm.api.v2.resources; -import com.google.common.io.Resources; import de.otto.edison.hal.HalRepresentation; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; @@ -8,6 +7,7 @@ import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.mock.MockDispatcherFactory; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; +import org.jboss.resteasy.spi.UnhandledException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -19,7 +19,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginLoader; -import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginState; import sonia.scm.plugin.PluginWrapper; import sonia.scm.web.VndMediaType; @@ -28,10 +27,10 @@ import javax.inject.Provider; import javax.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; -import java.net.URL; import java.util.Collections; 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.Mockito.mock; import static org.mockito.Mockito.when; @@ -40,7 +39,6 @@ import static org.mockito.Mockito.when; class InstalledPluginResourceTest { private Dispatcher dispatcher; - private URL resources = Resources.getResource("sonia/scm/api/v2/installedPlugins-001.json"); @Mock Provider installedPluginResourceProvider; @@ -48,9 +46,6 @@ class InstalledPluginResourceTest { @Mock Provider availablePluginResourceProvider; - @Mock - private PluginManager pluginManager; - @Mock private PluginLoader pluginLoader; @@ -91,17 +86,8 @@ class InstalledPluginResourceTest { @Test void getInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { - PluginInformation pluginInformation = new PluginInformation(); - pluginInformation.setVersion("2.0.0"); - pluginInformation.setName("plugin-name"); - pluginInformation.setState(PluginState.INSTALLED); - Plugin plugin = new Plugin(2, pluginInformation, null, null, false, null); - PluginWrapper pluginWrapper = new PluginWrapper(plugin, null, null, null); + PluginWrapper pluginWrapper = new PluginWrapper(null, null, null, null); when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(pluginWrapper)); - - PluginDto pluginDto = new PluginDto(); - pluginDto.setName("plugin-name"); - pluginDto.setVersion("2.0.0"); when(collectionMapper.map(Collections.singletonList(pluginWrapper))).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); @@ -115,13 +101,55 @@ class InstalledPluginResourceTest { } @Test - void getInstalledPlugin() { + void getInstalledPlugin() throws UnsupportedEncodingException, URISyntaxException { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setVersion("2.0.0"); + pluginInformation.setName("pluginName"); + pluginInformation.setState(PluginState.INSTALLED); + Plugin plugin = new Plugin(2, pluginInformation, null, null, false, null); + PluginWrapper pluginWrapper = new PluginWrapper(plugin, null, null, null); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(pluginWrapper)); + + PluginDto pluginDto = new PluginDto(); + pluginDto.setName("pluginName"); + when(mapper.map(pluginWrapper)).thenReturn(pluginDto); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed/pluginName"); + request.accept(VndMediaType.PLUGIN); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + assertThat(response.getContentAsString()).contains("\"name\":\"pluginName\""); + } + } + + @Nested + class WithoutAuthorization { + + @Test + void shouldNotGetInstalledPluginsIfMissingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); + request.accept(VndMediaType.PLUGIN_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); } - public class MockedResultDto extends HalRepresentation { - public String getMarker() { - return "x"; - } + @Test + void shouldNotGetInstalledPluginIfMissingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed/pluginName"); +// request.accept(VndMediaType.PLUGIN); + MockHttpResponse response = new MockHttpResponse(); + + assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + } + } + + public class MockedResultDto extends HalRepresentation { + public String getMarker() { + return "x"; } } } From 853bf133caa3f3adf43f2e09a40331fa2e42ced0 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 09:14:48 +0200 Subject: [PATCH 028/135] fix tests --- .../scm/api/v2/resources/AvailablePluginResourceTest.java | 6 ++++++ .../scm/api/v2/resources/InstalledPluginResourceTest.java | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 1ec1c52e81..da4ad9d29b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -133,6 +133,11 @@ class AvailablePluginResourceTest { @Nested class WithoutAuthorization { + @BeforeEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @Test void shouldNotGetAvailablePluginsIfMissingPermission() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); @@ -153,6 +158,7 @@ class AvailablePluginResourceTest { @Test void shouldNotInstallPluginIfMissingPermission() throws URISyntaxException { + ThreadContext.unbindSubject(); MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/2.0.0/install"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index f6633b4965..72aa8d4a5f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -140,7 +140,7 @@ class InstalledPluginResourceTest { @Test void shouldNotGetInstalledPluginIfMissingPermission() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed/pluginName"); -// request.accept(VndMediaType.PLUGIN); + request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); From 2fe8c154d3593859dbc87c879e97e6273ad9fc58 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 09:57:18 +0200 Subject: [PATCH 029/135] small fixes --- scm-ui/src/admin/plugins/components/PluginEntry.js | 6 +++--- .../scm/api/v2/resources/AvailablePluginResource.java | 6 ++++-- .../scm/api/v2/resources/AvailablePluginResourceTest.java | 7 +++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index 3693c2e284..b901cc0cfb 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -27,9 +27,9 @@ class PluginEntry extends React.Component { const { classes } = this.props; if (plugin._links && plugin._links.install && plugin._links.install.href) { return ( - - - +
console.log(plugin._links.install.href) /*TODO trigger plugin installation*/}> + +
); } }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index c3663b82b5..6d5711133f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -29,11 +29,13 @@ public class AvailablePluginResource { private final PluginDtoCollectionMapper collectionMapper; private final PluginManager pluginManager; + private final PluginDtoMapper mapper; @Inject - public AvailablePluginResource(PluginDtoCollectionMapper collectionMapper, PluginManager pluginManager) { + public AvailablePluginResource(PluginDtoCollectionMapper collectionMapper, PluginManager pluginManager, PluginDtoMapper mapper) { this.collectionMapper = collectionMapper; this.pluginManager = pluginManager; + this.mapper = mapper; } /** @@ -79,7 +81,7 @@ public class AvailablePluginResource { .filter(p -> p.getId().equals(name + ":" + version)) .findFirst(); if (plugin.isPresent()) { - return Response.ok(plugin.get()).build(); + return Response.ok(mapper.map(plugin.get())).build(); } else { throw notFound(entity(Plugin.class, name)); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index da4ad9d29b..57564999ef 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -52,6 +52,9 @@ class AvailablePluginResourceTest { @Mock private PluginManager pluginManager; + @Mock + private PluginDtoMapper mapper; + @InjectMocks AvailablePluginResource availablePluginResource; @@ -107,6 +110,10 @@ class AvailablePluginResourceTest { pluginInformation.setVersion("2.0.0"); when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(pluginInformation)); + PluginDto pluginDto = new PluginDto(); + pluginDto.setName("pluginName"); + when(mapper.map(pluginInformation)).thenReturn(pluginDto); + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName/2.0.0"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); From 1b1d3066648b97c0ab4ff61805a3af9aad0c6db2 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 10:00:49 +0200 Subject: [PATCH 030/135] remove unsed resource --- .../scm/api/v2/installedPlugins-001.json | 201 ------------------ 1 file changed, 201 deletions(-) delete mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json deleted file mode 100644 index 077851a9fa..0000000000 --- a/scm-webapp/src/test/resources/sonia/scm/api/v2/installedPlugins-001.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "_links": { - "self": { "href": "http://localhost:8081/scm/api/v2/plugins/installed" } - }, - "_embedded": { - "plugins": [ - { - "name": "scm-issuetracker-plugin", - "category": "Library Plugin", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Helper classes for issuetracker plugins.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-issuetracker-plugin" - } - } - }, - { - "name": "scm-webhook-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Notify a remote webserver whenever a repository is pushed to.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-webhook-plugin" - } - } - }, - { - "name": "scm-jenkins-plugin", - "category": "Continuous Integration", - "version": "2.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "This plugin will ping your Jenkins CI server when a new commit is pushed to SCM-Manager.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-jenkins-plugin" - } - } - }, - { - "name": "scm-legacy-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "The easiest way to share your Git, Mercurial\n and Subversion repositories over http.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-legacy-plugin" - } - } - }, - { - "name": "scm-jira-plugin", - "category": "Issue-Tracking", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "This plugin integrates Atlassian JIRA to SCM-Manager.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-jira-plugin" - } - } - }, - { - "name": "scm-ci-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Cloudogu GmbH", - "description": "SCM-Manager CI Plugin", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-ci-plugin" - } - } - }, - { - "name": "scm-svn-plugin", - "category": "Subversion", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Plugin for the version control system Subversion", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-svn-plugin" - } - } - }, - { - "name": "scm-hg-plugin", - "category": "Mercurial", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Plugin for the version control system Mercurial", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-hg-plugin" - } - } - }, - { - "name": "scm-review-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Cloudogu GmbH", - "description": "SCM-Manager Review Plugin", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-review-plugin" - } - } - }, - { - "name": "scm-git-plugin", - "category": "Git", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Plugin for the version control system Git", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-git-plugin" - } - } - }, - { - "name": "scm-script-plugin", - "category": "Development", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Script support for scm-manager.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-script-plugin" - } - } - }, - { - "name": "scm-mail-plugin", - "category": "Library Plugin", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "The mail plugin provides an api for sending e-mails.\n This api can be used by other plugins.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-mail-plugin" - } - } - }, - { - "name": "scm-authormapping-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Lookup and transform usernames to the real names stored in the scm-manager user database or in a mapping table.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-authormapping-plugin" - } - } - }, - { - "name": "scm-pushlog-plugin", - "category": "Miscellaneous", - "version": "2.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "Tracks who pushed what to a repository.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-pushlog-plugin" - } - } - }, - { - "name": "scm-branchwp-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "This plugin adds branch write protection for repositories.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-branchwp-plugin" - } - } - }, - { - "name": "scm-notify-plugin", - "category": "Miscellaneous", - "version": "2.0.0-SNAPSHOT", - "author": "Sebastian Sdorra", - "description": "This plugin sends email notifications to a list of subscribed addresses whenever a repo has changes.", - "_links": { - "self": { - "href": "http://localhost:8081/scm/api/v2/plugins/installed/scm-notify-plugin" - } - } - } - ] - } -} From a5681a30619029d559b5e018740f6f12fcc694db Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 10:08:00 +0200 Subject: [PATCH 031/135] remove unsed resource --- .../sonia/scm/plugin/plugincenter-001.json | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json b/scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json deleted file mode 100644 index d66f2f6c6b..0000000000 --- a/scm-webapp/src/test/resources/sonia/scm/plugin/plugincenter-001.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "_embedded": { - "plugins": [ - { - "name": "scm-branchwp-plugin", - "displayName": "SCM Branch WritePermission Plugin", - "description": "This plugin adds branch write protection for repositories.", - "category": "Miscellaneous", - "version": "1.0.0", - "author": "Sebastian Sdorra", - "sha256sum": "f6585d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", - "_links": { - "download": { - "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-branchwp-plugin/job/2.0.0/lastSuccessfulBuild/artifact/target/scm-branchwp-plugin-2.0.0-SNAPSHOT.smp" - } - } - }, - { - "name": "scm-pathwp-plugin", - "displayName": "SCM Path WritePermission Plugin", - "description": "This plugin adds path write protection for repositories.", - "category": "Miscellaneous", - "version": "2.1.0", - "author": "Sebastian Sdorra", - "conditions": { - "os": "linux", - "minVersion": "2.1.0" - }, - "sha256sum": "abc85d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", - "_links": { - "download": { - "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-pathwp-plugin/job/2.0.0/lastSuccessfulBuild/artifact/target/scm-pathwp-plugin-2.0.0-SNAPSHOT.smp" - } - } - }, - { - "name": "scm-mail-plugin", - "displayName": "SCM Mail Plugin", - "description": "The mail plugin provides an api for sending e-mails. This api can be used by other plugins.", - "category": "Library Plugin", - "version": "2.6.7", - "author": "Sebastian Sdorra", - "conditions": { - "os": "windows" - }, - "sha256sum": "def85d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", - "_links": { - "download": { - "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-mail-plugin/job/2.0.0/lastSuccessfulBuild/artifact/target/scm-mail-plugin-2.0.0-SNAPSHOT.smp" - } - } - }, - { - "name": "scm-review-plugin", - "displayName": "SCM Review Plugin", - "description": "SCM-Manager Review Plugin", - "category": "Miscellaneous", - "version": "2.4.0", - "author": "Sebastian Sdorra", - "conditions": { - "arch": "armv7" - }, - "sha256sum": "12385d369d3737415f05ea06e99048ac602fadcb76f5056b32b6cc232b9e8331", - "_links": { - "download": { - "href": "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-review-plugin/job/develop/lastSuccessfulBuild/artifact/target/scm-review-plugin-2.0.0-SNAPSHOT.smp" - } - } - } - ] - } -} From c7f5ae6bc7c61609485cfceb509c1351298c1f9e Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 10:13:18 +0200 Subject: [PATCH 032/135] fix sonar build --- .../sonia/scm/plugin/DefaultPluginManager.java | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index d17a693969..55b5dd9328 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -614,17 +614,6 @@ public class DefaultPluginManager implements PluginManager center.setPlugins(pluginInformationSet); preparePlugins(center); cache.put(PluginCenter.class.getName(), center); - - /* - * if (pluginHandler == null) - * { - * pluginHandler = new AetherPluginHandler(this, - * SCMContext.getContext(), configuration, - * advancedPluginConfiguration); - * } - * - * pluginHandler.setPluginRepositories(center.getRepositories()); - */ } catch (IOException ex) { @@ -632,6 +621,9 @@ public class DefaultPluginManager implements PluginManager } } } + if(center == null) { + center = new PluginCenter(); + } } return center; From b102c19f5f2960c8a68a361499dfcf28a1640858 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 11:53:08 +0200 Subject: [PATCH 033/135] fix test --- .../scm/api/v2/resources/InstalledPluginResourceTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index 72aa8d4a5f..a81eadadb8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -128,6 +128,11 @@ class InstalledPluginResourceTest { @Nested class WithoutAuthorization { + @BeforeEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @Test void shouldNotGetInstalledPluginsIfMissingPermission() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); From 86af7b23eb5e54780183014d1d2aca4eda4e2f63 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 15:43:12 +0200 Subject: [PATCH 034/135] remove groups from BearerRealm / SyncRealmHelper / DAORealmHelper --- .../sonia/scm/security/DAORealmHelper.java | 36 ++++++------- .../scm/security/DAORealmHelperFactory.java | 14 +++-- .../sonia/scm/security/GroupCollector.java | 33 ++++++++++-- .../sonia/scm/security/GroupResolver.java | 9 ++++ .../scm/security/SyncingRealmHelper.java | 8 +-- .../scm/security/DAORealmHelperTest.java | 27 +--------- .../scm/security/SyncingRealmHelperTest.java | 54 +++++-------------- .../java/sonia/scm/security/BearerRealm.java | 1 - .../sonia/scm/security/BearerRealmTest.java | 2 - 9 files changed, 80 insertions(+), 104 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/security/GroupResolver.java diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java index ea3e7ce9f5..6ec64a67de 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java @@ -45,7 +45,6 @@ import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.subject.SimplePrincipalCollection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.group.GroupDAO; import sonia.scm.user.User; import sonia.scm.user.UserDAO; @@ -71,8 +70,6 @@ public final class DAORealmHelper { private final UserDAO userDAO; - private final GroupCollector groupCollector; - private final String realm; //~--- constructors --------------------------------------------------------- @@ -83,14 +80,12 @@ public final class DAORealmHelper { * * @param loginAttemptHandler login attempt handler for wrapping credentials matcher * @param userDAO user dao - * @param groupCollector collect groups for a principal * @param realm name of realm */ - public DAORealmHelper(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, GroupCollector groupCollector, String realm) { + public DAORealmHelper(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, String realm) { this.loginAttemptHandler = loginAttemptHandler; this.realm = realm; this.userDAO = userDAO; - this.groupCollector = groupCollector; } //~--- get methods ---------------------------------------------------------- @@ -120,7 +115,7 @@ public final class DAORealmHelper { UsernamePasswordToken upt = (UsernamePasswordToken) token; String principal = upt.getUsername(); - return getAuthenticationInfo(principal, null, null, Collections.emptySet()); + return getAuthenticationInfo(principal, null, null); } /** @@ -135,7 +130,7 @@ public final class DAORealmHelper { } - private AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope, Iterable groups) { + private AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope) { checkArgument(!Strings.isNullOrEmpty(principal), "username is required"); LOG.debug("try to authenticate {}", principal); @@ -153,7 +148,6 @@ public final class DAORealmHelper { collection.add(principal, realm); collection.add(user, realm); - collection.add(groupCollector.collect(principal, groups), realm); collection.add(MoreObjects.firstNonNull(scope, Scope.empty()), realm); String creds = credentials; @@ -207,17 +201,17 @@ public final class DAORealmHelper { return this; } - /** - * With groups adds extra groups, besides those which come from the {@link GroupDAO}, to the authentication info. - * - * @param groups extra groups - * - * @return {@code this} - */ - public AuthenticationInfoBuilder withGroups(Iterable groups) { - this.groups = groups; - return this; - } +// /** +// * With groups adds extra groups, besides those which come from the {@link GroupDAO}, to the authentication info. +// * +// * @param groups extra groups +// * +// * @return {@code this} +// */ +// public AuthenticationInfoBuilder withGroups(Iterable groups) { +// this.groups = groups; +// return this; +// } /** * Build creates the authentication info from the given information. @@ -225,7 +219,7 @@ public final class DAORealmHelper { * @return authentication info */ public AuthenticationInfo build() { - return getAuthenticationInfo(principal, credentials, scope, groups); + return getAuthenticationInfo(principal, credentials, scope); } } diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java index ee2bf11e21..b503ff8375 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java @@ -30,6 +30,7 @@ */ package sonia.scm.security; +import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupDAO; import sonia.scm.user.UserDAO; @@ -45,20 +46,23 @@ public final class DAORealmHelperFactory { private final LoginAttemptHandler loginAttemptHandler; private final UserDAO userDAO; - private final GroupCollector groupCollector; + private final CacheManager cacheManager; + private final GroupResolver groupResolver; /** * Constructs a new instance. - * * @param loginAttemptHandler login attempt handler * @param userDAO user dao * @param groupDAO group dao + * @param cacheManager + * @param groupResolver */ @Inject - public DAORealmHelperFactory(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, GroupDAO groupDAO) { + public DAORealmHelperFactory(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, GroupDAO groupDAO, CacheManager cacheManager, GroupResolver groupResolver) { this.loginAttemptHandler = loginAttemptHandler; this.userDAO = userDAO; - this.groupCollector = new GroupCollector(groupDAO); + this.groupResolver = groupResolver; + this.cacheManager = cacheManager; } /** @@ -69,7 +73,7 @@ public final class DAORealmHelperFactory { * @return new {@link DAORealmHelper} instance. */ public DAORealmHelper create(String realm) { - return new DAORealmHelper(loginAttemptHandler, userDAO, groupCollector, realm); + return new DAORealmHelper(loginAttemptHandler, userDAO, realm); } } diff --git a/scm-core/src/main/java/sonia/scm/security/GroupCollector.java b/scm-core/src/main/java/sonia/scm/security/GroupCollector.java index 56687af7ef..06ac590a9a 100644 --- a/scm-core/src/main/java/sonia/scm/security/GroupCollector.java +++ b/scm-core/src/main/java/sonia/scm/security/GroupCollector.java @@ -3,10 +3,14 @@ package sonia.scm.security; import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; import sonia.scm.group.Group; import sonia.scm.group.GroupDAO; import sonia.scm.group.GroupNames; +import java.util.Set; + /** * Collect groups for a certain principal. * Warning: The class is only for internal use and should never used directly. @@ -15,18 +19,41 @@ class GroupCollector { private static final Logger LOG = LoggerFactory.getLogger(GroupCollector.class); + /** Field description */ + public static final String CACHE_NAME = "sonia.cache.externalGroups"; + + /** Field description */ + private final Cache cache; + private Set groupResolvers; + private final GroupDAO groupDAO; - GroupCollector(GroupDAO groupDAO) { + GroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { this.groupDAO = groupDAO; + this.cache = cacheManager.getCache(CACHE_NAME); + this.groupResolvers = groupResolvers; } - GroupNames collect(String principal, Iterable groupNames) { + Iterable collect(String principal) { + + Set externalGroups = cache.get(principal); + + if (externalGroups == null) { + ImmutableSet.Builder newExternalGroups = ImmutableSet.builder(); + + for (GroupResolver groupResolver : groupResolvers) { + Iterable groups = groupResolver.resolveGroups(principal); + groups.forEach(newExternalGroups::add); + } + + cache.put(principal, newExternalGroups.build()); + } + ImmutableSet.Builder builder = ImmutableSet.builder(); builder.add(GroupNames.AUTHENTICATED); - for (String group : groupNames) { + for (String group : externalGroups) { builder.add(group); } diff --git a/scm-core/src/main/java/sonia/scm/security/GroupResolver.java b/scm-core/src/main/java/sonia/scm/security/GroupResolver.java new file mode 100644 index 0000000000..3845628913 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/GroupResolver.java @@ -0,0 +1,9 @@ +package sonia.scm.security; + +import sonia.scm.plugin.ExtensionPoint; + +@ExtensionPoint +public interface GroupResolver { + + Iterable resolveGroups(String principal); +} diff --git a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java index d421d33f45..b209184902 100644 --- a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java @@ -36,6 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.AlreadyExistsException; import sonia.scm.NotFoundException; +import sonia.scm.cache.CacheManager; import sonia.scm.group.ExternalGroupNames; import sonia.scm.group.Group; import sonia.scm.group.GroupDAO; @@ -65,7 +66,7 @@ public final class SyncingRealmHelper { private final AdministrationContext ctx; private final UserManager userManager; private final GroupManager groupManager; - private final GroupCollector groupCollector; + private final CacheManager cacheManager; /** * Constructs a new SyncingRealmHelper. @@ -76,11 +77,11 @@ public final class SyncingRealmHelper { * @param groupDAO group dao */ @Inject - public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager, GroupDAO groupDAO) { + public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager, GroupDAO groupDAO, CacheManager cacheManager) { this.ctx = ctx; this.userManager = userManager; this.groupManager = groupManager; - this.groupCollector = new GroupCollector(groupDAO); + this.cacheManager = cacheManager; } /** @@ -199,7 +200,6 @@ public final class SyncingRealmHelper { collection.add(user.getId(), realm); collection.add(user, realm); - collection.add(groupCollector.collect(user.getId(), groups), realm); collection.add(new ExternalGroupNames(externalGroups), realm); return new SimpleAuthenticationInfo(collection, user.getPassword()); diff --git a/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java index 78dbd4fdd2..0fbcc20ac0 100644 --- a/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java @@ -1,20 +1,16 @@ package sonia.scm.security; -import com.google.common.collect.ImmutableList; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.DisabledAccountException; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.subject.PrincipalCollection; -import org.junit.Ignore; 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 sonia.scm.group.Group; import sonia.scm.group.GroupDAO; -import sonia.scm.group.GroupNames; import sonia.scm.user.User; import sonia.scm.user.UserDAO; @@ -38,7 +34,7 @@ class DAORealmHelperTest { @BeforeEach void setUpObjectUnderTest() { - helper = new DAORealmHelper(loginAttemptHandler, userDAO, new GroupCollector(groupDAO), "hitchhiker"); + helper = new DAORealmHelper(loginAttemptHandler, userDAO, "hitchhiker"); } @Test @@ -73,29 +69,9 @@ class DAORealmHelperTest { AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian").build(); PrincipalCollection principals = authenticationInfo.getPrincipals(); assertThat(principals.oneByType(User.class)).isSameAs(user); - assertThat(principals.oneByType(GroupNames.class)).containsOnly("_authenticated"); assertThat(principals.oneByType(Scope.class)).isEmpty(); } - @Test - @Ignore - void shouldReturnAuthenticationInfoWithGroups() { - User user = new User("trillian"); - when(userDAO.get("trillian")).thenReturn(user); - - Group one = new Group("xml", "one", "trillian"); - Group two = new Group("xml", "two", "trillian"); - Group six = new Group("xml", "six", "dent"); - when(groupDAO.getAll()).thenReturn(ImmutableList.of(one, two, six)); - - AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian") - .withGroups(ImmutableList.of("three")) - .build(); - - PrincipalCollection principals = authenticationInfo.getPrincipals(); - assertThat(principals.oneByType(GroupNames.class)).containsOnly("_authenticated", "one", "two", "three"); - } - @Test void shouldReturnAuthenticationInfoWithScope() { User user = new User("trillian"); @@ -148,7 +124,6 @@ class DAORealmHelperTest { PrincipalCollection principals = authenticationInfo.getPrincipals(); assertThat(principals.oneByType(User.class)).isSameAs(user); - assertThat(principals.oneByType(GroupNames.class)).containsOnly("_authenticated"); assertThat(principals.oneByType(Scope.class)).isEmpty(); assertThat(authenticationInfo.getCredentials()).isNull(); diff --git a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java index 20d1010b57..b7fbd97aac 100644 --- a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java @@ -36,7 +36,6 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.Throwables; -import com.google.common.collect.Lists; import org.apache.shiro.authc.AuthenticationInfo; import org.assertj.core.api.Assertions; import org.junit.Before; @@ -45,22 +44,26 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.AlreadyExistsException; +import sonia.scm.cache.CacheManager; import sonia.scm.group.ExternalGroupNames; import sonia.scm.group.Group; import sonia.scm.group.GroupDAO; import sonia.scm.group.GroupManager; -import sonia.scm.group.GroupNames; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.security.AdministrationContext; import sonia.scm.web.security.PrivilegedAction; import java.io.IOException; -import java.util.List; import static org.hamcrest.Matchers.hasItem; -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; //~--- JDK imports ------------------------------------------------------------ @@ -81,6 +84,9 @@ public class SyncingRealmHelperTest { @Mock private GroupDAO groupDAO; + @Mock + CacheManager cacheManager; + private SyncingRealmHelper helper; /** @@ -106,7 +112,7 @@ public class SyncingRealmHelperTest { } }; - helper = new SyncingRealmHelper(ctx, userManager, groupManager, groupDAO); + helper = new SyncingRealmHelper(ctx, userManager, groupManager, groupDAO, cacheManager); } /** @@ -183,19 +189,6 @@ public class SyncingRealmHelperTest { verify(userManager, times(1)).modify(user); } - @Test - public void builderShouldSetInternalGroups() { - AuthenticationInfo authenticationInfo = helper - .authenticationInfo() - .forRealm("unit-test") - .andUser(new User("ziltoid")) - .withGroups("internal") - .build(); - - GroupNames groupNames = authenticationInfo.getPrincipals().oneByType(GroupNames.class); - Assertions.assertThat(groupNames.getCollection()).contains("_authenticated", "internal"); - } - @Test public void builderShouldSetExternalGroups() { AuthenticationInfo authenticationInfo = helper @@ -223,27 +216,4 @@ public class SyncingRealmHelperTest { assertThat(authInfo.getPrincipals().getRealmNames(), hasItem("unit-test")); assertEquals(user, authInfo.getPrincipals().oneByType(User.class)); } - - @Test - public void shouldReturnCombinedGroupNames() { - User user = new User("tricia"); - - List groups = Lists.newArrayList(new Group("xml", "heartOfGold", "tricia")); - when(groupDAO.getAll()).thenReturn(groups); - - AuthenticationInfo authInfo = helper - .authenticationInfo() - .forRealm("unit-test") - .andUser(user) - .withGroups("fjordsOfAfrican") - .withExternalGroups("g42") - .build(); - - - GroupNames groupNames = authInfo.getPrincipals().oneByType(GroupNames.class); - Assertions.assertThat(groupNames).contains("_authenticated", "heartOfGold", "fjordsOfAfrican"); - - ExternalGroupNames externalGroupNames = authInfo.getPrincipals().oneByType(ExternalGroupNames.class); - Assertions.assertThat(externalGroupNames).contains("g42"); - } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java index b237a0a5ff..324dfe9082 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -104,7 +104,6 @@ public class BearerRealm extends AuthenticatingRealm return helper.authenticationInfoBuilder(accessToken.getSubject()) .withCredentials(bt.getCredentials()) .withScope(Scopes.fromClaims(accessToken.getClaims())) - .withGroups(accessToken.getGroups()) .build(); } diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index c2d75358fd..d458f9c72c 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -90,12 +90,10 @@ class BearerRealmTest { Set groups = ImmutableSet.of("HeartOfGold", "Puzzle42"); when(accessToken.getSubject()).thenReturn("trillian"); - when(accessToken.getGroups()).thenReturn(groups); when(accessToken.getClaims()).thenReturn(new HashMap<>()); when(accessTokenResolver.resolve(bearerToken)).thenReturn(accessToken); when(realmHelper.authenticationInfoBuilder("trillian")).thenReturn(builder); - when(builder.withGroups(groups)).thenReturn(builder); when(builder.withCredentials("__bearer__")).thenReturn(builder); when(builder.withScope(any(Scope.class))).thenReturn(builder); when(builder.build()).thenReturn(authenticationInfo); From 2cff893d738da2cfde605af1d3eeb8934d2b758c Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 1 Aug 2019 16:05:22 +0200 Subject: [PATCH 035/135] add interface in core + move groupCollector to webapp --- .../sonia/scm/security/GroupCollector.java | 69 +----------------- .../scm/group/DefaultGroupCollector.java | 70 +++++++++++++++++++ .../sonia/scm/group}/GroupCollectorTest.java | 14 ++-- 3 files changed, 77 insertions(+), 76 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java rename {scm-core/src/test/java/sonia/scm/security => scm-webapp/src/test/java/sonia/scm/group}/GroupCollectorTest.java (74%) diff --git a/scm-core/src/main/java/sonia/scm/security/GroupCollector.java b/scm-core/src/main/java/sonia/scm/security/GroupCollector.java index 06ac590a9a..6c9bf2e659 100644 --- a/scm-core/src/main/java/sonia/scm/security/GroupCollector.java +++ b/scm-core/src/main/java/sonia/scm/security/GroupCollector.java @@ -1,70 +1,5 @@ package sonia.scm.security; -import com.google.common.collect.ImmutableSet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.cache.Cache; -import sonia.scm.cache.CacheManager; -import sonia.scm.group.Group; -import sonia.scm.group.GroupDAO; -import sonia.scm.group.GroupNames; - -import java.util.Set; - -/** - * Collect groups for a certain principal. - * Warning: The class is only for internal use and should never used directly. - */ -class GroupCollector { - - private static final Logger LOG = LoggerFactory.getLogger(GroupCollector.class); - - /** Field description */ - public static final String CACHE_NAME = "sonia.cache.externalGroups"; - - /** Field description */ - private final Cache cache; - private Set groupResolvers; - - private final GroupDAO groupDAO; - - GroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { - this.groupDAO = groupDAO; - this.cache = cacheManager.getCache(CACHE_NAME); - this.groupResolvers = groupResolvers; - } - - Iterable collect(String principal) { - - Set externalGroups = cache.get(principal); - - if (externalGroups == null) { - ImmutableSet.Builder newExternalGroups = ImmutableSet.builder(); - - for (GroupResolver groupResolver : groupResolvers) { - Iterable groups = groupResolver.resolveGroups(principal); - groups.forEach(newExternalGroups::add); - } - - cache.put(principal, newExternalGroups.build()); - } - - ImmutableSet.Builder builder = ImmutableSet.builder(); - - builder.add(GroupNames.AUTHENTICATED); - - for (String group : externalGroups) { - builder.add(group); - } - - for (Group group : groupDAO.getAll()) { - if (group.isMember(principal)) { - builder.add(group.getName()); - } - } - - GroupNames groups = new GroupNames(builder.build()); - LOG.debug("collected following groups for principal {}: {}", principal, groups); - return groups; - } +public interface GroupCollector { + Iterable collect(String principal); } diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java new file mode 100644 index 0000000000..cdaf0eb7b2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java @@ -0,0 +1,70 @@ +package sonia.scm.group; + +import com.google.common.collect.ImmutableSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.security.GroupCollector; +import sonia.scm.security.GroupResolver; + +import java.util.Set; + +/** + * Collect groups for a certain principal. + * Warning: The class is only for internal use and should never used directly. + */ +class DefaultGroupCollector implements GroupCollector { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultGroupCollector.class); + + /** Field description */ + public static final String CACHE_NAME = "sonia.cache.externalGroups"; + + /** Field description */ + private final Cache> cache; + private Set groupResolvers; + + private final GroupDAO groupDAO; + + DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { + this.groupDAO = groupDAO; + this.cache = cacheManager.getCache(CACHE_NAME); + this.groupResolvers = groupResolvers; + } + + @Override + public Iterable collect(String principal) { + + Set externalGroups = cache.get(principal); + + if (externalGroups == null) { + ImmutableSet.Builder newExternalGroups = ImmutableSet.builder(); + + for (GroupResolver groupResolver : groupResolvers) { + Iterable groups = groupResolver.resolveGroups(principal); + groups.forEach(newExternalGroups::add); + } + + cache.put(principal, newExternalGroups.build()); + } + + ImmutableSet.Builder builder = ImmutableSet.builder(); + + builder.add(GroupNames.AUTHENTICATED); + + for (String group : externalGroups) { + builder.add(group); + } + + for (Group group : groupDAO.getAll()) { + if (group.isMember(principal)) { + builder.add(group.getName()); + } + } + + GroupNames groups = new GroupNames(builder.build()); + LOG.debug("collected following groups for principal {}: {}", principal, groups); + return groups; + } +} diff --git a/scm-core/src/test/java/sonia/scm/security/GroupCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java similarity index 74% rename from scm-core/src/test/java/sonia/scm/security/GroupCollectorTest.java rename to scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java index 3fb59d1614..4bdcd2694a 100644 --- a/scm-core/src/test/java/sonia/scm/security/GroupCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java @@ -1,6 +1,5 @@ -package sonia.scm.security; +package sonia.scm.group; -import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -9,11 +8,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import sonia.scm.group.Group; -import sonia.scm.group.GroupDAO; -import sonia.scm.group.GroupNames; +import sonia.scm.security.GroupCollector; -import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -30,7 +26,7 @@ class GroupCollectorTest { @Test void shouldAlwaysReturnAuthenticatedGroup() { - GroupNames groupNames = collector.collect("trillian", Collections.emptySet()); + Iterable groupNames = collector.collect("trillian"); assertThat(groupNames).containsOnly("_authenticated"); } @@ -49,13 +45,13 @@ class GroupCollectorTest { @Test void shouldReturnGroupsFromDao() { - GroupNames groupNames = collector.collect("trillian", Collections.emptySet()); + Iterable groupNames = collector.collect("trillian"); assertThat(groupNames).contains("_authenticated", "heartOfGold", "fjordsOfAfrican"); } @Test void shouldCombineGivenWithDao() { - GroupNames groupNames = collector.collect("trillian", ImmutableList.of("awesome", "incredible")); + Iterable groupNames = collector.collect("trillian"); assertThat(groupNames).contains("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible"); } From 8550baaea902b041481e3af251c1e86cac5230cc Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 08:17:17 +0200 Subject: [PATCH 036/135] refactor GroupResolver + GroupCollector --- .../java/sonia/scm/group/GroupCollector.java | 10 ++ .../{security => group}/GroupResolver.java | 7 +- .../sonia/scm/security/GroupCollector.java | 5 - .../scm/group/DefaultGroupCollector.java | 70 ++++++------ .../scm/group/DefaultGroupCollectorTest.java | 100 ++++++++++++++++++ .../sonia/scm/group/GroupCollectorTest.java | 60 ----------- 6 files changed, 150 insertions(+), 102 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/group/GroupCollector.java rename scm-core/src/main/java/sonia/scm/{security => group}/GroupResolver.java (51%) delete mode 100644 scm-core/src/main/java/sonia/scm/security/GroupCollector.java create mode 100644 scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java delete mode 100644 scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java diff --git a/scm-core/src/main/java/sonia/scm/group/GroupCollector.java b/scm-core/src/main/java/sonia/scm/group/GroupCollector.java new file mode 100644 index 0000000000..4546db1bc4 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/group/GroupCollector.java @@ -0,0 +1,10 @@ +package sonia.scm.group; + +import java.util.Set; + +public interface GroupCollector { + + String AUTHENTICATED = "_authenticated"; + + Set collect(String principal); +} diff --git a/scm-core/src/main/java/sonia/scm/security/GroupResolver.java b/scm-core/src/main/java/sonia/scm/group/GroupResolver.java similarity index 51% rename from scm-core/src/main/java/sonia/scm/security/GroupResolver.java rename to scm-core/src/main/java/sonia/scm/group/GroupResolver.java index 3845628913..5aba63c93b 100644 --- a/scm-core/src/main/java/sonia/scm/security/GroupResolver.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupResolver.java @@ -1,9 +1,10 @@ -package sonia.scm.security; +package sonia.scm.group; import sonia.scm.plugin.ExtensionPoint; +import java.util.Set; + @ExtensionPoint public interface GroupResolver { - - Iterable resolveGroups(String principal); + Set resolve(String principal); } diff --git a/scm-core/src/main/java/sonia/scm/security/GroupCollector.java b/scm-core/src/main/java/sonia/scm/security/GroupCollector.java deleted file mode 100644 index 6c9bf2e659..0000000000 --- a/scm-core/src/main/java/sonia/scm/security/GroupCollector.java +++ /dev/null @@ -1,5 +0,0 @@ -package sonia.scm.security; - -public interface GroupCollector { - Iterable collect(String principal); -} diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java index cdaf0eb7b2..8c6bac8103 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java @@ -1,70 +1,72 @@ package sonia.scm.group; +import com.cronutils.utils.VisibleForTesting; import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; -import sonia.scm.security.GroupCollector; -import sonia.scm.security.GroupResolver; +import javax.inject.Inject; +import javax.inject.Singleton; import java.util.Set; /** * Collect groups for a certain principal. * Warning: The class is only for internal use and should never used directly. */ -class DefaultGroupCollector implements GroupCollector { +@Singleton +public class DefaultGroupCollector implements GroupCollector { private static final Logger LOG = LoggerFactory.getLogger(DefaultGroupCollector.class); - /** Field description */ - public static final String CACHE_NAME = "sonia.cache.externalGroups"; - - /** Field description */ - private final Cache> cache; - private Set groupResolvers; + @VisibleForTesting + static final String CACHE_NAME = "sonia.cache.externalGroups"; private final GroupDAO groupDAO; + private final Cache> cache; + private final Set groupResolvers; - DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { + @Inject + public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { this.groupDAO = groupDAO; - this.cache = cacheManager.getCache(CACHE_NAME); + this.cache = cacheManager.getCache(CACHE_NAME); this.groupResolvers = groupResolvers; } @Override - public Iterable collect(String principal) { + public Set collect(String principal) { + ImmutableSet.Builder builder = ImmutableSet.builder(); + builder.add(AUTHENTICATED); + builder.addAll(resolveExternalGroups(principal)); + appendInternalGroups(principal, builder); + + Set groups = builder.build(); + LOG.debug("collected following groups for principal {}: {}", principal, groups); + return groups; + } + + private void appendInternalGroups(String principal, ImmutableSet.Builder builder) { + for (Group group : groupDAO.getAll()) { + if (group.isMember(principal)) { + builder.add(group.getName()); + } + } + } + + private Set resolveExternalGroups(String principal) { Set externalGroups = cache.get(principal); if (externalGroups == null) { ImmutableSet.Builder newExternalGroups = ImmutableSet.builder(); for (GroupResolver groupResolver : groupResolvers) { - Iterable groups = groupResolver.resolveGroups(principal); - groups.forEach(newExternalGroups::add); + newExternalGroups.addAll(groupResolver.resolve(principal)); } - - cache.put(principal, newExternalGroups.build()); + externalGroups = newExternalGroups.build(); + cache.put(principal, externalGroups); } - - ImmutableSet.Builder builder = ImmutableSet.builder(); - - builder.add(GroupNames.AUTHENTICATED); - - for (String group : externalGroups) { - builder.add(group); - } - - for (Group group : groupDAO.getAll()) { - if (group.isMember(principal)) { - builder.add(group.getName()); - } - } - - GroupNames groups = new GroupNames(builder.build()); - LOG.debug("collected following groups for principal {}: {}", principal, groups); - return groups; + return externalGroups; } } diff --git a/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java new file mode 100644 index 0000000000..edd23151b9 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java @@ -0,0 +1,100 @@ +package sonia.scm.group; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.cache.MapCache; +import sonia.scm.cache.MapCacheManager; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultGroupCollectorTest { + + @Mock + private GroupDAO groupDAO; + + @Mock + private GroupResolver groupResolver; + + private MapCacheManager mapCacheManager; + + private Set groupResolvers; + + private DefaultGroupCollector collector; + + @BeforeEach + void initCollector() { + groupResolvers = new HashSet<>(); + mapCacheManager = new MapCacheManager(); + collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers); + } + + @Test + void shouldAlwaysReturnAuthenticatedGroup() { + Iterable groupNames = collector.collect("trillian"); + assertThat(groupNames).containsOnly("_authenticated"); + } + + @Test + void shouldReturnGroupsFromCache() { + MapCache> cache = mapCacheManager.getCache(DefaultGroupCollector.CACHE_NAME); + cache.put("trillian", ImmutableSet.of("awesome", "incredible")); + + Set groups = collector.collect("trillian"); + assertThat(groups).containsOnly("_authenticated", "awesome", "incredible"); + } + + @Test + void shouldNotCallResolverIfExternalGroupsAreCached() { + groupResolvers.add(groupResolver); + + MapCache> cache = mapCacheManager.getCache(DefaultGroupCollector.CACHE_NAME); + cache.put("trillian", ImmutableSet.of("awesome", "incredible")); + + Set groups = collector.collect("trillian"); + assertThat(groups).containsOnly("_authenticated", "awesome", "incredible"); + + verify(groupResolver, never()).resolve("trillian"); + } + + @Nested + class WithGroupsFromDao { + + @BeforeEach + void setUpGroupsDao() { + List groups = Lists.newArrayList( + new Group("xml", "heartOfGold", "trillian"), + new Group("xml", "g42", "dent", "prefect"), + new Group("xml", "fjordsOfAfrican", "dent", "trillian") + ); + when(groupDAO.getAll()).thenReturn(groups); + } + + @Test + void shouldReturnGroupsFromDao() { + Iterable groupNames = collector.collect("trillian"); + assertThat(groupNames).containsOnly("_authenticated", "heartOfGold", "fjordsOfAfrican"); + } + + @Test + void shouldCombineWithResolvers() { + when(groupResolver.resolve("trillian")).thenReturn(ImmutableSet.of("awesome", "incredible")); + groupResolvers.add(groupResolver); + Iterable groupNames = collector.collect("trillian"); + assertThat(groupNames).containsOnly("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible"); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java deleted file mode 100644 index 4bdcd2694a..0000000000 --- a/scm-webapp/src/test/java/sonia/scm/group/GroupCollectorTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package sonia.scm.group; - -import com.google.common.collect.Lists; -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.security.GroupCollector; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class GroupCollectorTest { - - @Mock - private GroupDAO groupDAO; - - @InjectMocks - private GroupCollector collector; - - @Test - void shouldAlwaysReturnAuthenticatedGroup() { - Iterable groupNames = collector.collect("trillian"); - assertThat(groupNames).containsOnly("_authenticated"); - } - - @Nested - class WithGroupsFromDao { - - @BeforeEach - void setUpGroupsDao() { - List groups = Lists.newArrayList( - new Group("xml", "heartOfGold", "trillian"), - new Group("xml", "g42", "dent", "prefect"), - new Group("xml", "fjordsOfAfrican", "dent", "trillian") - ); - when(groupDAO.getAll()).thenReturn(groups); - } - - @Test - void shouldReturnGroupsFromDao() { - Iterable groupNames = collector.collect("trillian"); - assertThat(groupNames).contains("_authenticated", "heartOfGold", "fjordsOfAfrican"); - } - - @Test - void shouldCombineGivenWithDao() { - Iterable groupNames = collector.collect("trillian"); - assertThat(groupNames).contains("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible"); - } - - } - -} From 442aacbcdb073baa7fbcba60b5b34256bec11ffb Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 09:32:44 +0200 Subject: [PATCH 037/135] remove GroupNames and ExternalGroupNames in favor of GroupCollector --- .../sonia/scm/group/ExternalGroupNames.java | 22 --- .../main/java/sonia/scm/group/GroupNames.java | 187 ------------------ .../scm/security/AccessTokenBuilder.java | 9 - .../scm/security/DAORealmHelperFactory.java | 7 +- .../scm/security/SyncingRealmHelper.java | 122 +----------- .../scm/security/SyncingRealmHelperTest.java | 30 +-- .../sonia/scm/api/v2/resources/MeDto.java | 4 +- .../scm/api/v2/resources/MeDtoFactory.java | 18 +- .../DefaultAuthorizationCollector.java | 32 +-- .../java/sonia/scm/security/DefaultRealm.java | 18 +- .../scm/security/JwtAccessTokenBuilder.java | 18 -- .../DefaultAdministrationContext.java | 10 +- .../api/v2/resources/MeDtoFactoryTest.java | 26 +-- .../DefaultAuthorizationCollectorTest.java | 16 +- .../sonia/scm/security/DefaultRealmTest.java | 97 +++------ .../security/JwtAccessTokenBuilderTest.java | 50 +---- 16 files changed, 100 insertions(+), 566 deletions(-) delete mode 100644 scm-core/src/main/java/sonia/scm/group/ExternalGroupNames.java delete mode 100644 scm-core/src/main/java/sonia/scm/group/GroupNames.java diff --git a/scm-core/src/main/java/sonia/scm/group/ExternalGroupNames.java b/scm-core/src/main/java/sonia/scm/group/ExternalGroupNames.java deleted file mode 100644 index 179e488236..0000000000 --- a/scm-core/src/main/java/sonia/scm/group/ExternalGroupNames.java +++ /dev/null @@ -1,22 +0,0 @@ -package sonia.scm.group; - -import java.util.Collection; - -/** - * This class represents all associated groups which are provided by external systems for a certain user. - * - * @author Sebastian Sdorra - * @since 2.0.0 - */ -public class ExternalGroupNames extends GroupNames { - public ExternalGroupNames() { - } - - public ExternalGroupNames(String groupName, String... groupNames) { - super(groupName, groupNames); - } - - public ExternalGroupNames(Collection collection) { - super(collection); - } -} diff --git a/scm-core/src/main/java/sonia/scm/group/GroupNames.java b/scm-core/src/main/java/sonia/scm/group/GroupNames.java deleted file mode 100644 index c28f9f5ef1..0000000000 --- a/scm-core/src/main/java/sonia/scm/group/GroupNames.java +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.group; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Joiner; -import com.google.common.base.Objects; -import com.google.common.collect.Lists; - -import java.io.Serializable; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; - -//~--- JDK imports ------------------------------------------------------------ - -/** - * This class represents all associated groups for a user. - * - * @author Sebastian Sdorra - * @since 1.21 - */ -public class GroupNames implements Serializable, Iterable -{ - - /** - * Group for all authenticated users - * @since 1.31 - */ - public static final String AUTHENTICATED = "_authenticated"; - - /** Field description */ - private static final long serialVersionUID = 8615685985213897947L; - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - */ - public GroupNames() - { - this(Collections.emptyList()); - } - - /** - * Constructs ... - * - * - * @param groupName - * @param groupNames - */ - public GroupNames(String groupName, String... groupNames) - { - this(Lists.asList(groupName, groupNames)); - } - - /** - * Constructs ... - * - * - * @param collection - */ - public GroupNames(Collection collection) - { - this.collection = Collections.unmodifiableCollection(collection); - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param groupName - * - * @return - */ - public boolean contains(String groupName) - { - return collection.contains(groupName); - } - - /** - * {@inheritDoc} - */ - @Override - public boolean equals(Object obj) - { - if (obj == null) - { - return false; - } - - if (getClass() != obj.getClass()) - { - return false; - } - - final GroupNames other = (GroupNames) obj; - - return Objects.equal(collection, other.collection); - } - - /** - * {@inheritDoc} - */ - @Override - public int hashCode() - { - return Objects.hashCode(collection); - } - - /** - * Method description - * - * - * @return - */ - @Override - public Iterator iterator() - { - return collection.iterator(); - } - - /** - * Method description - * - * - * @return - */ - @Override - public String toString() - { - return Joiner.on(", ").join(collection); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public Collection getCollection() - { - return collection; - } - - - //~--- fields --------------------------------------------------------------- - /** Field description */ - private final Collection collection; -} diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java index 0924716bd8..afe81ac27f 100644 --- a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java @@ -99,15 +99,6 @@ public interface AccessTokenBuilder { */ AccessTokenBuilder scope(Scope scope); - /** - * Define the logged in user as member of the given groups. - * - * @param groups group names - * - * @return {@code this} - */ - AccessTokenBuilder groups(String... groups); - /** * Creates a new {@link AccessToken} with the provided settings. * diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java index b503ff8375..dd59de2ac8 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java @@ -31,7 +31,6 @@ package sonia.scm.security; import sonia.scm.cache.CacheManager; -import sonia.scm.group.GroupDAO; import sonia.scm.user.UserDAO; import javax.inject.Inject; @@ -47,21 +46,17 @@ public final class DAORealmHelperFactory { private final LoginAttemptHandler loginAttemptHandler; private final UserDAO userDAO; private final CacheManager cacheManager; - private final GroupResolver groupResolver; /** * Constructs a new instance. * @param loginAttemptHandler login attempt handler * @param userDAO user dao - * @param groupDAO group dao * @param cacheManager - * @param groupResolver */ @Inject - public DAORealmHelperFactory(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, GroupDAO groupDAO, CacheManager cacheManager, GroupResolver groupResolver) { + public DAORealmHelperFactory(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, CacheManager cacheManager) { this.loginAttemptHandler = loginAttemptHandler; this.userDAO = userDAO; - this.groupResolver = groupResolver; this.cacheManager = cacheManager; } diff --git a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java index b209184902..b2175f304a 100644 --- a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java @@ -32,25 +32,15 @@ import com.google.inject.Inject; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.subject.SimplePrincipalCollection; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import sonia.scm.AlreadyExistsException; import sonia.scm.NotFoundException; -import sonia.scm.cache.CacheManager; -import sonia.scm.group.ExternalGroupNames; import sonia.scm.group.Group; -import sonia.scm.group.GroupDAO; import sonia.scm.group.GroupManager; import sonia.scm.plugin.Extension; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.security.AdministrationContext; -import java.util.Collection; -import java.util.Collections; - -import static java.util.Arrays.asList; - /** * Helper class for syncing realms. The class should simplify the creation of realms, which are syncing authenticated * users with the local database. @@ -61,12 +51,9 @@ import static java.util.Arrays.asList; @Extension public final class SyncingRealmHelper { - private static final Logger LOG = LoggerFactory.getLogger(SyncingRealmHelper.class); - private final AdministrationContext ctx; private final UserManager userManager; private final GroupManager groupManager; - private final CacheManager cacheManager; /** * Constructs a new SyncingRealmHelper. @@ -74,133 +61,28 @@ public final class SyncingRealmHelper { * @param ctx administration context * @param userManager user manager * @param groupManager group manager - * @param groupDAO group dao */ @Inject - public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager, GroupDAO groupDAO, CacheManager cacheManager) { + public SyncingRealmHelper(AdministrationContext ctx, UserManager userManager, GroupManager groupManager) { this.ctx = ctx; this.userManager = userManager; this.groupManager = groupManager; - this.cacheManager = cacheManager; } - /** - * Create {@link AuthenticationInfo} from user and groups. - */ - public AuthenticationInfoBuilder.ForRealm authenticationInfo() { - return new AuthenticationInfoBuilder().new ForRealm(); - } - - public class AuthenticationInfoBuilder { - private String realm; - private User user; - private Collection groups = Collections.emptySet(); - private Collection externalGroups = Collections.emptySet(); - - private AuthenticationInfo build() { - return SyncingRealmHelper.this.createAuthenticationInfo(realm, user, groups, externalGroups); - } - - public class ForRealm { - private ForRealm() { - } - - /** - * Sets the realm. - * @param realm name of the realm - */ - public ForUser forRealm(String realm) { - AuthenticationInfoBuilder.this.realm = realm; - return AuthenticationInfoBuilder.this.new ForUser(); - } - } - - public class ForUser { - private ForUser() { - } - - /** - * Sets the user. - * @param user authenticated user - */ - public AuthenticationInfoBuilder.WithGroups andUser(User user) { - AuthenticationInfoBuilder.this.user = user; - return AuthenticationInfoBuilder.this.new WithGroups(); - } - } - - public class WithGroups { - private WithGroups() { - } - - /** - * Set the internal groups for the user. - * @param groups groups of the authenticated user - * @return builder step for groups - */ - public WithGroups withGroups(String... groups) { - return withGroups(asList(groups)); - } - - /** - * Set the internal groups for the user. - * @param groups groups of the authenticated user - * @return builder step for groups - */ - public WithGroups withGroups(Collection groups) { - AuthenticationInfoBuilder.this.groups = groups; - return this; - } - - /** - * Set the external groups for the user. - * @param externalGroups external groups of the authenticated user - * @return builder step for groups - */ - public WithGroups withExternalGroups(String... externalGroups) { - return withExternalGroups(asList(externalGroups)); - } - - /** - * Set the external groups for the user. - * @param externalGroups external groups of the authenticated user - * @return builder step for groups - */ - public WithGroups withExternalGroups(Collection externalGroups) { - AuthenticationInfoBuilder.this.externalGroups = externalGroups; - return this; - } - - /** - * Builds the {@link AuthenticationInfo} from the given options. - * - * @return complete autentication info - */ - public AuthenticationInfo build() { - return AuthenticationInfoBuilder.this.build(); - } - } - } - - //~--- methods -------------------------------------------------------------- - /** * Create {@link AuthenticationInfo} from user and groups. * * * @param realm name of the realm * @param user authenticated user - * @param groups groups of the authenticated user * * @return authentication info */ - private AuthenticationInfo createAuthenticationInfo(String realm, User user, - Collection groups, Collection externalGroups) { + public AuthenticationInfo createAuthenticationInfo(String realm, User user) { SimplePrincipalCollection collection = new SimplePrincipalCollection(); collection.add(user.getId(), realm); collection.add(user, realm); - collection.add(new ExternalGroupNames(externalGroups), realm); return new SimpleAuthenticationInfo(collection, user.getPassword()); } diff --git a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java index b7fbd97aac..ca7c2efdc6 100644 --- a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java @@ -37,17 +37,13 @@ package sonia.scm.security; import com.google.common.base.Throwables; import org.apache.shiro.authc.AuthenticationInfo; -import org.assertj.core.api.Assertions; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.AlreadyExistsException; -import sonia.scm.cache.CacheManager; -import sonia.scm.group.ExternalGroupNames; import sonia.scm.group.Group; -import sonia.scm.group.GroupDAO; import sonia.scm.group.GroupManager; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -81,12 +77,6 @@ public class SyncingRealmHelperTest { @Mock private UserManager userManager; - @Mock - private GroupDAO groupDAO; - - @Mock - CacheManager cacheManager; - private SyncingRealmHelper helper; /** @@ -112,7 +102,7 @@ public class SyncingRealmHelperTest { } }; - helper = new SyncingRealmHelper(ctx, userManager, groupManager, groupDAO, cacheManager); + helper = new SyncingRealmHelper(ctx, userManager, groupManager); } /** @@ -189,27 +179,11 @@ public class SyncingRealmHelperTest { verify(userManager, times(1)).modify(user); } - @Test - public void builderShouldSetExternalGroups() { - AuthenticationInfo authenticationInfo = helper - .authenticationInfo() - .forRealm("unit-test") - .andUser(new User("ziltoid")) - .withExternalGroups("external") - .build(); - - ExternalGroupNames groupNames = authenticationInfo.getPrincipals().oneByType(ExternalGroupNames.class); - Assertions.assertThat(groupNames.getCollection()).containsOnly("external"); - } @Test public void builderShouldSetValues() { User user = new User("ziltoid"); - AuthenticationInfo authInfo = helper - .authenticationInfo() - .forRealm("unit-test") - .andUser(user) - .build(); + AuthenticationInfo authInfo = helper.createAuthenticationInfo("unit-test", user); assertNotNull(authInfo); assertEquals("ziltoid", authInfo.getPrincipals().getPrimaryPrincipal()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java index 84fbbfe290..a87ad7df17 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java @@ -7,7 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.util.List; +import java.util.Set; @Getter @Setter @@ -17,7 +17,7 @@ public class MeDto extends HalRepresentation { private String name; private String displayName; private String mail; - private List groups; + private Set groups; MeDto(Links links, Embedded embedded) { super(links, embedded); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java index b5e1998066..c2bebd389a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java @@ -1,18 +1,16 @@ package sonia.scm.api.v2.resources; -import com.google.common.collect.ImmutableList; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.Subject; -import sonia.scm.group.GroupNames; +import sonia.scm.group.GroupCollector; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.user.UserPermissions; import javax.inject.Inject; -import java.util.Collections; import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; @@ -22,11 +20,13 @@ public class MeDtoFactory extends HalAppenderMapper { private final ResourceLinks resourceLinks; private final UserManager userManager; + private final GroupCollector groupCollector; @Inject - public MeDtoFactory(ResourceLinks resourceLinks, UserManager userManager) { + public MeDtoFactory(ResourceLinks resourceLinks, UserManager userManager, GroupCollector groupCollector) { this.resourceLinks = resourceLinks; this.userManager = userManager; + this.groupCollector = groupCollector; } public MeDto create() { @@ -35,16 +35,12 @@ public class MeDtoFactory extends HalAppenderMapper { MeDto dto = createDto(user); mapUserProperties(user, dto); - mapGroups(principals, dto); + mapGroups(user, dto); return dto; } - private void mapGroups(PrincipalCollection principals, MeDto dto) { - Iterable groups = principals.oneByType(GroupNames.class); - if (groups == null) { - groups = Collections.emptySet(); - } - dto.setGroups(ImmutableList.copyOf(groups)); + private void mapGroups(User user, MeDto dto) { + dto.setGroups(groupCollector.collect(user.getName())); } private void mapUserProperties(User user, MeDto dto) { diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index baff3b951c..28f61df34f 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -52,17 +52,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; -import sonia.scm.group.GroupNames; +import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupPermissions; import sonia.scm.plugin.Extension; -import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.user.UserPermissions; import sonia.scm.util.Util; import java.util.Collection; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -88,19 +89,21 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector /** * Constructs ... - * @param cacheManager + * @param cacheManager * @param repositoryDAO * @param securitySystem * @param repositoryPermissionProvider + * @param groupCollector */ @Inject public DefaultAuthorizationCollector(CacheManager cacheManager, - RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider) + RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector) { this.cache = cacheManager.getCache(CACHE_NAME); this.repositoryDAO = repositoryDAO; this.securitySystem = securitySystem; this.repositoryPermissionProvider = repositoryPermissionProvider; + this.groupCollector = groupCollector; } //~--- methods -------------------------------------------------------------- @@ -145,16 +148,16 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector Preconditions.checkNotNull(user, "no user found in principal collection"); - GroupNames groupNames = principals.oneByType(GroupNames.class); + Set groups = groupCollector.collect(user.getName()); - CacheKey cacheKey = new CacheKey(user.getId(), groupNames); + CacheKey cacheKey = new CacheKey(user.getId(), groups); AuthorizationInfo info = cache.get(cacheKey); if (info == null) { logger.trace("collect AuthorizationInfo for user {}", user.getName()); - info = createAuthorizationInfo(user, groupNames); + info = createAuthorizationInfo(user, groups); cache.put(cacheKey, info); } else if (logger.isTraceEnabled()) @@ -166,7 +169,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector } private void collectGlobalPermissions(Builder builder, - final User user, final GroupNames groups) + final User user, final Set groups) { Collection globalPermissions = securitySystem.getPermissions((AssignedPermission input) -> isUserPermitted(user, groups, input)); @@ -181,7 +184,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector } private void collectRepositoryPermissions(Builder builder, User user, - GroupNames groups) + Set groups) { for (Repository repository : repositoryDAO.getAll()) { @@ -190,7 +193,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector } private void collectRepositoryPermissions(Builder builder, - Repository repository, User user, GroupNames groups) + Repository repository, User user, Set groups) { Collection repositoryPermissions = repository.getPermissions(); @@ -245,7 +248,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector .getVerbs(); } - private AuthorizationInfo createAuthorizationInfo(User user, GroupNames groups) { + private AuthorizationInfo createAuthorizationInfo(User user, Set groups) { Builder builder = ImmutableSet.builder(); collectGlobalPermissions(builder, user, groups); @@ -279,7 +282,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector //~--- get methods ---------------------------------------------------------- - private boolean isUserPermitted(User user, GroupNames groups, + private boolean isUserPermitted(User user, Set groups, PermissionObject perm) { //J- @@ -314,7 +317,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector */ private static class CacheKey { - private CacheKey(String username, GroupNames groupnames) + private CacheKey(String username, Set groupnames) { this.username = username; this.groupnames = groupnames; @@ -356,7 +359,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector //~--- fields ------------------------------------------------------------- /** group names */ - private final GroupNames groupnames; + private final Set groupnames; /** username */ private final String username; @@ -374,4 +377,5 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private final SecuritySystem securitySystem; private final RepositoryPermissionProvider repositoryPermissionProvider; + private final GroupCollector groupCollector; } diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index bacbd9b314..245dcadb78 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -34,7 +34,6 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.annotations.VisibleForTesting; - import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; @@ -45,21 +44,16 @@ import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; - -import org.apache.shiro.subject.SimplePrincipalCollection; -import sonia.scm.group.GroupNames; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import sonia.scm.plugin.Extension; -//~--- JDK imports ------------------------------------------------------------ - import javax.inject.Inject; import javax.inject.Singleton; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.Set; +//~--- JDK imports ------------------------------------------------------------ + /** * Default authorizing realm. * @@ -149,7 +143,7 @@ public class DefaultRealm extends AuthorizingRealm LOG.trace("principal does not contain scope information, returning all permissions"); log(principals, info, null); } - + return info; } @@ -180,8 +174,6 @@ public class DefaultRealm extends AuthorizingRealm StringBuilder buffer = new StringBuilder("authorization summary: "); buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal()); - buffer.append(SEPARATOR).append("groups : "); - append(buffer, collection.oneByType(GroupNames.class)); buffer.append(SEPARATOR).append("roles : "); append(buffer, original.getRoles()); buffer.append(SEPARATOR).append("scope : "); diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java index cc1d4be7a7..5a74f77502 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java @@ -40,11 +40,9 @@ import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.group.ExternalGroupNames; import java.time.Clock; import java.time.Instant; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; @@ -139,12 +137,6 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { return this; } - @Override - public JwtAccessTokenBuilder groups(String... groups) { - Collections.addAll(this.groups, groups); - return this; - } - JwtAccessTokenBuilder refreshExpiration(Instant refreshExpiration) { this.refreshExpiration = refreshExpiration; this.refreshableFor = 0; @@ -206,16 +198,6 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { claims.setIssuer(issuer); } - if (!groups.isEmpty()) { - claims.put(JwtAccessToken.GROUPS_CLAIM_KEY, groups); - } else { - Subject currentSubject = SecurityUtils.getSubject(); - ExternalGroupNames externalGroupNames = currentSubject.getPrincipals().oneByType(ExternalGroupNames.class); - if (externalGroupNames != null) { - claims.put(JwtAccessToken.GROUPS_CLAIM_KEY, externalGroupNames.getCollection().toArray(new String[]{})); - } - } - // sign token and create compact version String compact = Jwts.builder() .setClaims(claims) diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java b/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java index 0b380c8088..b62b6c63f3 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java +++ b/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java @@ -38,7 +38,6 @@ package sonia.scm.web.security; import com.google.inject.Inject; import com.google.inject.Injector; import com.google.inject.Singleton; - import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.SimplePrincipalCollection; @@ -46,21 +45,17 @@ import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.support.SubjectThreadState; import org.apache.shiro.util.ThreadContext; import org.apache.shiro.util.ThreadState; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.SCMContext; -import sonia.scm.group.GroupNames; import sonia.scm.security.Role; import sonia.scm.user.User; import sonia.scm.util.AssertUtil; -//~--- JDK imports ------------------------------------------------------------ - +import javax.xml.bind.JAXB; import java.net.URL; -import javax.xml.bind.JAXB; +//~--- JDK imports ------------------------------------------------------------ /** * @@ -161,7 +156,6 @@ public class DefaultAdministrationContext implements AdministrationContext collection.add(adminUser.getId(), REALM); collection.add(adminUser, REALM); - collection.add(new GroupNames(), REALM); collection.add(AdministrationContextMarker.MARKER, REALM); return collection; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java index 8a00c69229..d9572dc04c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java @@ -1,9 +1,9 @@ package sonia.scm.api.v2.resources; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; -import org.assertj.core.util.Lists; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,7 +12,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import sonia.scm.group.GroupNames; +import sonia.scm.group.GroupCollector; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.user.UserTestData; @@ -20,7 +20,6 @@ import sonia.scm.user.UserTestData; import java.net.URI; 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; @@ -33,6 +32,9 @@ class MeDtoFactoryTest { @Mock private UserManager userManager; + @Mock + private GroupCollector groupCollector; + @Mock private Subject subject; @@ -42,7 +44,7 @@ class MeDtoFactoryTest { void setUpContext() { ThreadContext.bind(subject); ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); - meDtoFactory = new MeDtoFactory(resourceLinks, userManager); + meDtoFactory = new MeDtoFactory(resourceLinks, userManager, groupCollector); } @AfterEach @@ -69,24 +71,16 @@ class MeDtoFactoryTest { @Test void shouldCreateMeDtoWithGroups() { - prepareSubject(UserTestData.createTrillian(), "HeartOfGold", "Puzzle42"); + when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("HeartOfGold", "Puzzle42")); + prepareSubject(UserTestData.createTrillian()); MeDto dto = meDtoFactory.create(); assertThat(dto.getGroups()).containsOnly("HeartOfGold", "Puzzle42"); } - private void prepareSubject(User user, String... groups) { + private void prepareSubject(User user) { PrincipalCollection collection = mock(PrincipalCollection.class); when(subject.getPrincipals()).thenReturn(collection); - when(collection.oneByType(any(Class.class))).then(ic -> { - Class type = ic.getArgument(0); - if (type.isAssignableFrom(User.class)) { - return user; - } else if (type.isAssignableFrom(GroupNames.class)) { - return new GroupNames(Lists.newArrayList(groups)); - } else { - return null; - } - }); + when(collection.oneByType(User.class)).thenReturn(user); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index 8e7cb8a70e..930a06d249 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -33,6 +33,7 @@ package sonia.scm.security; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; @@ -49,7 +50,7 @@ import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; -import sonia.scm.group.GroupNames; +import sonia.scm.group.GroupCollector; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; @@ -58,8 +59,6 @@ import sonia.scm.repository.RepositoryTestData; import sonia.scm.user.User; import sonia.scm.user.UserTestData; -import java.util.Collections; - import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -96,6 +95,9 @@ public class DefaultAuthorizationCollectorTest { @Mock private RepositoryPermissionProvider repositoryPermissionProvider; + @Mock + private GroupCollector groupCollector; + private DefaultAuthorizationCollector collector; @Rule @@ -107,7 +109,7 @@ public class DefaultAuthorizationCollectorTest { @Before public void setUp(){ when(cacheManager.getCache(Mockito.any(String.class))).thenReturn(cache); - collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider); + collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector); } /** @@ -290,9 +292,13 @@ public class DefaultAuthorizationCollectorTest { SimplePrincipalCollection spc = new SimplePrincipalCollection(); spc.add(user.getName(), "unit"); spc.add(user, "unit"); - spc.add(new GroupNames(group, groups), "unit"); Subject subject = new Subject.Builder().authenticated(true).principals(spc).buildSubject(); shiro.setSubject(subject); + + ImmutableSet.Builder builder = ImmutableSet.builder(); + builder.add(group); + builder.add(groups); + when(groupCollector.collect(user.getName())).thenReturn(builder.build()); } /** diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java index b6fea9e897..ba23411b36 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java @@ -36,8 +36,6 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.collect.Collections2; -import com.google.common.collect.Lists; - import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.DisabledAccountException; @@ -45,43 +43,44 @@ import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.DefaultPasswordService; -import org.apache.shiro.crypto.hash.DefaultHashService; -import org.apache.shiro.subject.PrincipalCollection; -import org.apache.shiro.subject.SimplePrincipalCollection; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - -import sonia.scm.group.Group; -import sonia.scm.group.GroupDAO; -import sonia.scm.group.GroupNames; -import sonia.scm.user.User; -import sonia.scm.user.UserDAO; -import sonia.scm.user.UserTestData; - -import static org.hamcrest.Matchers.*; - -import static org.junit.Assert.*; - -import static org.mockito.Mockito.*; - -//~--- JDK imports ------------------------------------------------------------ - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.Permission; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.authz.permission.WildcardPermissionResolver; +import org.apache.shiro.crypto.hash.DefaultHashService; +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.SimplePrincipalCollection; import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.group.GroupDAO; +import sonia.scm.user.User; +import sonia.scm.user.UserDAO; +import sonia.scm.user.UserTestData; + +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -206,32 +205,6 @@ public class DefaultRealmTest ); } - /** - * Method description - * - */ - @Test - public void testGroupCollection() - { - User user = UserTestData.createTrillian(); - //J- - List groups = Lists.newArrayList( - new Group(DefaultRealm.REALM, "scm", user.getName()), - new Group(DefaultRealm.REALM, "developers", "perfect") - ); - //J+ - - when(groupDAO.getAll()).thenReturn(groups); - - UsernamePasswordToken token = daoUser(user, "secret"); - AuthenticationInfo info = realm.getAuthenticationInfo(token); - GroupNames groupNames = info.getPrincipals().oneByType(GroupNames.class); - - assertNotNull(groupNames); - assertThat(groupNames.getCollection(), hasSize(2)); - assertThat(groupNames, hasItems("scm", GroupNames.AUTHENTICATED)); - } - /** * Method description * @@ -251,12 +224,6 @@ public class DefaultRealmTest assertThat(collection.getRealmNames(), hasSize(1)); assertThat(collection.getRealmNames(), hasItem(DefaultRealm.REALM)); assertEquals(user, collection.oneByType(User.class)); - - GroupNames groups = collection.oneByType(GroupNames.class); - - assertNotNull(groups); - assertThat(groups.getCollection(), hasSize(1)); - assertThat(groups.getCollection(), hasItem(GroupNames.AUTHENTICATED)); } /** diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java index e2117235fc..7a8c0ef169 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java @@ -36,27 +36,25 @@ import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.Sets; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; -import org.apache.shiro.SecurityUtils; -import org.apache.shiro.subject.PrincipalCollection; -import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.group.ExternalGroupNames; -import java.util.Arrays; -import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import static org.hamcrest.Matchers.isEmptyOrNullString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.when; import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; /** @@ -137,7 +135,6 @@ public class JwtAccessTokenBuilderTest { .issuer("https://www.scm-manager.org") .expiresIn(5, TimeUnit.SECONDS) .custom("a", "b") - .groups("one", "two", "three") .scope(Scope.valueOf("repo:*")) .build(); @@ -154,36 +151,6 @@ public class JwtAccessTokenBuilderTest { assertClaims(new JwtAccessToken(claims, compact)); } - @Test - public void testWithExternalGroups() { - applyExternalGroupsToSubject(true, "external"); - JwtAccessToken token = factory.create().subject("dent").build(); - assertArrayEquals(new String[]{"external"}, token.getCustom(JwtAccessToken.GROUPS_CLAIM_KEY).map(x -> (String[]) x).get()); - } - - @Test - public void testWithInternalGroups() { - applyExternalGroupsToSubject(false, "external"); - JwtAccessToken token = factory.create().subject("dent").build(); - assertFalse(token.getCustom(JwtAccessToken.GROUPS_CLAIM_KEY).isPresent()); - } - - private void applyExternalGroupsToSubject(boolean external, String... groups) { - Subject subject = spy(SecurityUtils.getSubject()); - when(subject.getPrincipals()).thenAnswer(invocation -> enrichWithGroups(invocation, groups, external)); - shiro.setSubject(subject); - } - - private Object enrichWithGroups(InvocationOnMock invocation, String[] groups, boolean external) throws Throwable { - PrincipalCollection principals = (PrincipalCollection) spy(invocation.callRealMethod()); - - List groupCollection = Arrays.asList(groups); - if (external) { - when(principals.oneByType(ExternalGroupNames.class)).thenReturn(new ExternalGroupNames(groupCollection)); - } - return principals; - } - private void assertClaims(JwtAccessToken token){ assertThat(token.getId(), not(isEmptyOrNullString())); assertNotNull( token.getIssuedAt() ); @@ -194,6 +161,5 @@ public class JwtAccessTokenBuilderTest { assertEquals(token.getIssuer().get(), "https://www.scm-manager.org"); assertEquals("b", token.getCustom("a").get()); assertEquals("[\"repo:*\"]", token.getScope().toString()); - assertThat(token.getGroups(), containsInAnyOrder("one", "two", "three")); } } From b029b80f63e53257d640f0ea17390b983ccd9477 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 09:54:40 +0200 Subject: [PATCH 038/135] fix failed unit test after refactoring MeDtoFactory --- .../sonia/scm/api/v2/resources/MeResourceTest.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java index cd2a172c1b..7a3d1b4304 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java @@ -2,6 +2,7 @@ package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.subject.PrincipalCollection; @@ -16,6 +17,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.ContextEntry; +import sonia.scm.group.GroupCollector; import sonia.scm.user.InvalidPasswordException; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -28,7 +30,12 @@ import java.net.URISyntaxException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher; @@ -50,6 +57,9 @@ public class MeResourceTest { @Mock private ScmPathInfoStore scmPathInfoStore; + @Mock + private GroupCollector groupCollector; + @Mock private UserManager userManager; @@ -69,6 +79,7 @@ public class MeResourceTest { when(userManager.create(userCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]); doNothing().when(userManager).modify(userCaptor.capture()); doNothing().when(userManager).delete(userCaptor.capture()); + when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2")); when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod(); when(userManager.getDefaultType()).thenReturn("xml"); MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService); From ef0c57b83f1575b7f4a306483a96f6be06d6f40c Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 09:55:30 +0200 Subject: [PATCH 039/135] bind GroupCollector to DefaultGroupCollector --- .../java/sonia/scm/lifecycle/modules/ScmServletModule.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index 7ece64f719..cd34d03b50 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -50,8 +50,10 @@ import sonia.scm.cache.CacheManager; import sonia.scm.cache.GuavaCacheManager; import sonia.scm.config.ScmConfiguration; import sonia.scm.event.ScmEventBus; +import sonia.scm.group.DefaultGroupCollector; import sonia.scm.group.DefaultGroupDisplayManager; import sonia.scm.group.DefaultGroupManager; +import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupDAO; import sonia.scm.group.GroupDisplayManager; import sonia.scm.group.GroupManager; @@ -195,7 +197,7 @@ class ScmServletModule extends ServletModule { bindDecorated(GroupManager.class, DefaultGroupManager.class, GroupManagerProvider.class); bind(GroupDisplayManager.class, DefaultGroupDisplayManager.class); - + bind(GroupCollector.class, DefaultGroupCollector.class); bind(CGIExecutorFactory.class, DefaultCGIExecutorFactory.class); // bind sslcontext provider From b4bee0758ddbafbe2c8d3ecc23383776aae88602 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 09:55:51 +0200 Subject: [PATCH 040/135] adjust caching for external groups --- scm-webapp/src/main/resources/config/gcache.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scm-webapp/src/main/resources/config/gcache.xml b/scm-webapp/src/main/resources/config/gcache.xml index eb59f36446..28876558df 100644 --- a/scm-webapp/src/main/resources/config/gcache.xml +++ b/scm-webapp/src/main/resources/config/gcache.xml @@ -40,15 +40,15 @@ /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 47e323e468daf1783d7c6b144b8cc03c8ad706ae Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 12:01:53 +0200 Subject: [PATCH 042/135] disable shiro cache for the DefaultRealm --- scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index 245dcadb78..e65c88e679 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -97,6 +97,9 @@ public class DefaultRealm extends AuthorizingRealm matcher.setPasswordService(service); setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); setAuthenticationTokenClass(UsernamePasswordToken.class); + + // we cache in the AuthorizationCollector + setCachingEnabled(false); } //~--- methods -------------------------------------------------------------- From 0890707b3fe52e0b3ad344726176e9beb00d4b77 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 14:46:33 +0200 Subject: [PATCH 043/135] fix permission reference on clone repositories --- .../java/sonia/scm/repository/Repository.java | 4 +++- .../sonia/scm/repository/RepositoryTest.java | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 scm-core/src/test/java/sonia/scm/repository/RepositoryTest.java diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index 5bb50db06f..8c7000c25a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -82,7 +82,7 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per private String namespace; private String name; @XmlElement(name = "permission") - private final Set permissions = new HashSet<>(); + private Set permissions = new HashSet<>(); @XmlElement(name = "public") private boolean publicReadable = false; private boolean archived = false; @@ -331,6 +331,8 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per try { repository = (Repository) super.clone(); + // fix permission reference on clone + repository.permissions = new HashSet<>(permissions); } catch (CloneNotSupportedException ex) { throw new RuntimeException(ex); } diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryTest.java new file mode 100644 index 0000000000..6053e10ad5 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryTest.java @@ -0,0 +1,22 @@ +package sonia.scm.repository; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class RepositoryTest { + + @Test + void shouldCreateNewPermissionOnClone() { + Repository repository = new Repository(); + repository.setPermissions(Arrays.asList(new RepositoryPermission("one", "role", false))); + + Repository cloned = repository.clone(); + cloned.setPermissions(Arrays.asList(new RepositoryPermission("two", "role", false))); + + assertThat(repository.getPermissions()).extracting(r -> r.getName()).containsOnly("one"); + } + +} From e33b4dc9fc16a1eb8eb30db48437726119b62782 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 14:46:58 +0200 Subject: [PATCH 044/135] removed unused method --- .../test/java/sonia/scm/it/utils/TestData.java | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java index 20de47ffa4..d632f13f60 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -123,23 +123,6 @@ public class TestData { ; } - public static void createUserPermission(String username, String roleName, String repositoryType) { - String defaultPermissionUrl = TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType); - LOG.info("create permission with name {} and role {} using the endpoint: {}", username, roleName, defaultPermissionUrl); - given(VndMediaType.REPOSITORY_PERMISSION) - .when() - .content("{\n" + - "\t\"role\": " + roleName + ",\n" + - "\t\"name\": \"" + username + "\",\n" + - "\t\"groupPermission\": false\n" + - "\t\n" + - "}") - .post(defaultPermissionUrl) - .then() - .statusCode(HttpStatus.SC_CREATED) - ; - } - public static List getUserPermissions(String username, String password, String repositoryType) { return callUserPermissions(username, password, repositoryType, HttpStatus.SC_OK) .extract() From 63c874917f5fc355572f813d07ca787bf85650fd Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 2 Aug 2019 15:02:13 +0200 Subject: [PATCH 045/135] added singleton annotation to AuthorizationChangedEventProducer to be sure that it is not destroyed by the gc --- .../sonia/scm/security/AuthorizationChangedEventProducer.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java index 66dbb51073..3f81377992 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java @@ -48,6 +48,8 @@ import sonia.scm.user.User; import sonia.scm.user.UserEvent; import sonia.scm.user.UserModificationEvent; +import javax.inject.Singleton; + /** * Receives all kinds of events, which affects authorization relevant data and fires an * {@link AuthorizationChangedEvent} if authorization data has changed. @@ -55,6 +57,7 @@ import sonia.scm.user.UserModificationEvent; * @author Sebastian Sdorra * @since 1.52 */ +@Singleton @EagerSingleton public class AuthorizationChangedEventProducer { From 254665099c2ccaa01bc993886aabbe654055bfad Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 11:32:05 +0200 Subject: [PATCH 046/135] added parameter for extra docker tag --- Jenkinsfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index e817ac2ba1..62ccc23cc1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -13,6 +13,9 @@ node('docker') { // Keep only the last 10 build to preserve space buildDiscarder(logRotator(numToKeepStr: '10')), disableConcurrentBuilds() + parameters([ + string(name: 'dockerTag', trim: true, description: 'Extra Docker Tag for cloudogu/scm-manager image') + ]) ]) timeout(activity: true, time: 30, unit: 'MINUTES') { @@ -66,6 +69,9 @@ node('docker') { docker.withRegistry('', 'hub.docker.com-cesmarvin') { image.push(dockerImageTag) image.push('latest') + if (params.dockerTag) { + image.push(dockerTag) + } } } From 13b5bcbb295fa696dad2e9245d1c528ac46e6c8f Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 11:36:31 +0200 Subject: [PATCH 047/135] fixed broken build --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 62ccc23cc1..4faa57325d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,7 +12,7 @@ node('docker') { properties([ // Keep only the last 10 build to preserve space buildDiscarder(logRotator(numToKeepStr: '10')), - disableConcurrentBuilds() + disableConcurrentBuilds(), parameters([ string(name: 'dockerTag', trim: true, description: 'Extra Docker Tag for cloudogu/scm-manager image') ]) From 09068a55d47641b0f626eed07e3d653bf11c8a96 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 13:02:57 +0200 Subject: [PATCH 048/135] push extra docker image tag with hash and version --- Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Jenkinsfile b/Jenkinsfile index 4faa57325d..f8a7be2ad9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -71,6 +71,7 @@ node('docker') { image.push('latest') if (params.dockerTag) { image.push(dockerTag) + image.push("2.0.0-${commitHash.substring(0,7)}-dev-${dockerTag}") } } } From c885385ca4e36cd7e9669015814bd9df94548c17 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 13:32:31 +0200 Subject: [PATCH 049/135] fix null value as environment variable --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index f8a7be2ad9..37406886ba 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -70,8 +70,8 @@ node('docker') { image.push(dockerImageTag) image.push('latest') if (params.dockerTag) { - image.push(dockerTag) - image.push("2.0.0-${commitHash.substring(0,7)}-dev-${dockerTag}") + image.push(params.dockerTag) + image.push("2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}") } } } From 036a7594479f820ca2610796b058596a5367a8a2 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 13:45:10 +0200 Subject: [PATCH 050/135] set defaultValue of dockerTag parameter to an empty string --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 37406886ba..3e35a31afe 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -14,7 +14,7 @@ node('docker') { buildDiscarder(logRotator(numToKeepStr: '10')), disableConcurrentBuilds(), parameters([ - string(name: 'dockerTag', trim: true, description: 'Extra Docker Tag for cloudogu/scm-manager image') + string(name: 'dockerTag', trim: true, defaultValue: "", description: 'Extra Docker Tag for cloudogu/scm-manager image') ]) ]) From 681d81a8fa1c0aeb08fb2aba0d53a3e1ffaab4d5 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 13:49:04 +0200 Subject: [PATCH 051/135] set description for builds with dockerTag parameter --- Jenkinsfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 3e35a31afe..e36a78b385 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -71,7 +71,10 @@ node('docker') { image.push('latest') if (params.dockerTag) { image.push(params.dockerTag) - image.push("2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}") + + def newDockerTag = "2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}" + currentBuild.description = newDockerTag + image.push(newDockerTag) } } } From 9f0ebe57cd7a08bac4afaac357fc2a20022015dd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 13:57:28 +0200 Subject: [PATCH 052/135] set default dockerTag to latest Jenkins treat every build parameter as environment variable and empty or null values lead to an error, see https://issues.jenkins-ci.org/browse/JENKINS-38608 --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index e36a78b385..1fc9786dd5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -14,7 +14,7 @@ node('docker') { buildDiscarder(logRotator(numToKeepStr: '10')), disableConcurrentBuilds(), parameters([ - string(name: 'dockerTag', trim: true, defaultValue: "", description: 'Extra Docker Tag for cloudogu/scm-manager image') + string(name: 'dockerTag', trim: true, defaultValue: "latest", description: 'Extra Docker Tag for cloudogu/scm-manager image') ]) ]) @@ -69,7 +69,7 @@ node('docker') { docker.withRegistry('', 'hub.docker.com-cesmarvin') { image.push(dockerImageTag) image.push('latest') - if (params.dockerTag) { + if (!"latest".equals(params.dockerTag)) { image.push(params.dockerTag) def newDockerTag = "2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}" From ce882d2ee70952433b15d570dcc32847eef8bc24 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 6 Aug 2019 16:50:28 +0200 Subject: [PATCH 053/135] use single quotes for string without interpolation --- Jenkinsfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 1fc9786dd5..36135cf4a4 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -7,14 +7,14 @@ import com.cloudogu.ces.cesbuildlib.* node('docker') { // Change this as when we go back to default - necessary for proper SonarQube analysis - mainBranch = "2.0.0-m3" + mainBranch = '2.0.0-m3' properties([ // Keep only the last 10 build to preserve space buildDiscarder(logRotator(numToKeepStr: '10')), disableConcurrentBuilds(), parameters([ - string(name: 'dockerTag', trim: true, defaultValue: "latest", description: 'Extra Docker Tag for cloudogu/scm-manager image') + string(name: 'dockerTag', trim: true, defaultValue: 'latest', description: 'Extra Docker Tag for cloudogu/scm-manager image') ]) ]) @@ -69,7 +69,7 @@ node('docker') { docker.withRegistry('', 'hub.docker.com-cesmarvin') { image.push(dockerImageTag) image.push('latest') - if (!"latest".equals(params.dockerTag)) { + if (!'latest'.equals(params.dockerTag)) { image.push(params.dockerTag) def newDockerTag = "2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}" @@ -102,7 +102,7 @@ String mainBranch Maven setupMavenBuild() { // Keep this version number in sync with .mvn/maven-wrapper.properties - Maven mvn = new MavenInDocker(this, "3.5.2-jdk-8") + Maven mvn = new MavenInDocker(this, '3.5.2-jdk-8') if (isMainBranch()) { // Release starts javadoc, which takes very long, so do only for certain branches From e9454bb77958292385058b56ca45f3ef210e0162 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 7 Aug 2019 07:21:30 +0200 Subject: [PATCH 054/135] revert defaultValue for dockerTag --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 36135cf4a4..5ca6c60234 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -14,7 +14,7 @@ node('docker') { buildDiscarder(logRotator(numToKeepStr: '10')), disableConcurrentBuilds(), parameters([ - string(name: 'dockerTag', trim: true, defaultValue: 'latest', description: 'Extra Docker Tag for cloudogu/scm-manager image') + string(name: 'dockerTag', trim: true, description: 'Extra Docker Tag for cloudogu/scm-manager image') ]) ]) @@ -69,7 +69,7 @@ node('docker') { docker.withRegistry('', 'hub.docker.com-cesmarvin') { image.push(dockerImageTag) image.push('latest') - if (!'latest'.equals(params.dockerTag)) { + if (params.dockerTag) { image.push(params.dockerTag) def newDockerTag = "2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}" From c34900aa30a8891cec8a9e663b7dbb0c97be0d45 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 7 Aug 2019 09:15:07 +0200 Subject: [PATCH 055/135] revert defaultValue change again --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 5ca6c60234..36135cf4a4 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -14,7 +14,7 @@ node('docker') { buildDiscarder(logRotator(numToKeepStr: '10')), disableConcurrentBuilds(), parameters([ - string(name: 'dockerTag', trim: true, description: 'Extra Docker Tag for cloudogu/scm-manager image') + string(name: 'dockerTag', trim: true, defaultValue: 'latest', description: 'Extra Docker Tag for cloudogu/scm-manager image') ]) ]) @@ -69,7 +69,7 @@ node('docker') { docker.withRegistry('', 'hub.docker.com-cesmarvin') { image.push(dockerImageTag) image.push('latest') - if (params.dockerTag) { + if (!'latest'.equals(params.dockerTag)) { image.push(params.dockerTag) def newDockerTag = "2.0.0-${commitHash.substring(0,7)}-dev-${params.dockerTag}" From 074c0e51b3942e918f5d8d0beda04fa17cd40fe8 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 7 Aug 2019 13:36:01 +0200 Subject: [PATCH 056/135] added scm-manager logo variations --- docs/logo/favicon_16x16px.ico | Bin 0 -> 1150 bytes docs/logo/favicon_16x16px_transparent.ico | Bin 0 -> 1150 bytes docs/logo/scm-manager_logo.ai | 1 + docs/logo/scm-manager_logo.jpg | Bin 0 -> 45667 bytes docs/logo/scm-manager_logo.png | Bin 0 -> 41610 bytes docs/logo/scm-manager_logo_img.jpg | Bin 0 -> 34748 bytes docs/logo/scm-manager_logo_img.png | Bin 0 -> 33758 bytes docs/logo/scm-manager_logo_img_neg.jpg | Bin 0 -> 25370 bytes docs/logo/scm-manager_logo_img_neg.png | Bin 0 -> 27443 bytes docs/logo/scm-manager_logo_neg.jpg | Bin 0 -> 47177 bytes docs/logo/scm-manager_logo_neg.png | Bin 0 -> 25105 bytes docs/logo/scm-manager_logo_neg1.jpg | Bin 0 -> 34398 bytes docs/logo/scm-manager_logo_neg1.png | Bin 0 -> 19272 bytes docs/logo/scm-manager_logo_pos1.jpg | Bin 0 -> 34444 bytes docs/logo/scm-manager_logo_pos1.png | Bin 0 -> 34079 bytes 15 files changed, 1 insertion(+) create mode 100644 docs/logo/favicon_16x16px.ico create mode 100644 docs/logo/favicon_16x16px_transparent.ico create mode 100644 docs/logo/scm-manager_logo.ai create mode 100644 docs/logo/scm-manager_logo.jpg create mode 100644 docs/logo/scm-manager_logo.png create mode 100644 docs/logo/scm-manager_logo_img.jpg create mode 100644 docs/logo/scm-manager_logo_img.png create mode 100644 docs/logo/scm-manager_logo_img_neg.jpg create mode 100644 docs/logo/scm-manager_logo_img_neg.png create mode 100644 docs/logo/scm-manager_logo_neg.jpg create mode 100644 docs/logo/scm-manager_logo_neg.png create mode 100644 docs/logo/scm-manager_logo_neg1.jpg create mode 100644 docs/logo/scm-manager_logo_neg1.png create mode 100644 docs/logo/scm-manager_logo_pos1.jpg create mode 100644 docs/logo/scm-manager_logo_pos1.png diff --git a/docs/logo/favicon_16x16px.ico b/docs/logo/favicon_16x16px.ico new file mode 100644 index 0000000000000000000000000000000000000000..3436795fdfcd58cab1d3edf3ac5593a8ca4ba7ec GIT binary patch literal 1150 zcmcIk&r1S96n;UsE<(^bA}Hvx*sY8G2SMk8j?q6*hYtP;MgKyFAWcJvAgV(qDQ+Pe z2wSPUxr({1D{lBJzUHlLb&=ddZ+Y`(-n{RdZ+3&NeqWKhd>Wc#Na^y!b6o7KLh(crqrPvx?uXet*@Sa^fN9J?Q z)|2?C+@Y2}fL%^H?#^-K|LtmLEOU7=*2;Tk4)+d?isw7)>ehAL(K0HQ>v&yW-?E0E lix)nxwM4c}WbjMSBFd@Gqd<-8LF85yIj!_Fc6&i^?+Xoq(Zm1% literal 0 HcmV?d00001 diff --git a/docs/logo/favicon_16x16px_transparent.ico b/docs/logo/favicon_16x16px_transparent.ico new file mode 100644 index 0000000000000000000000000000000000000000..e5803f340df8c825ba1344e189e192e34f983a9f GIT binary patch literal 1150 zcmcJNJ4?e*6vwY1D7X{^7l+0sX#^39F22Bg1N#x&baZzx;NY0i`oM@*P<$hbqBYf_ z;2>GF)!Le>^_33!01nsxX-#nuYX)!m_1xZbe&-(2Fjj|8Ai(e~u+e(PS{P&P07p2f zoFgz5x#p${^!vY7DLEMR+dGv?MSwtF$sCN9P6t%l`&sa}sUG*MPNLuP<5Der6O?Cn z5`FyrsN3+<1FivV5O5N`_S<0>f8Fo=bB`~dBkImckNJ8K;V*j~{CP()i<_cQYZiDk zKl%LOc*rQby9zIEN$%5n$Sth4Y4QR1&YB^pW zjsU#5$FQ$H6R+mCh!15P2(S&u?z4Hil8t7f{j9<0TCA`!Pz8ZlgLeh L1`y;UtsLM0i literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo.ai b/docs/logo/scm-manager_logo.ai new file mode 100644 index 0000000000..6fe98a15e0 --- /dev/null +++ b/docs/logo/scm-manager_logo.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (scm-manager_logo.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 800 275 %%HiResBoundingBox: 0 0 800 275 %AI3_Cropmarks: 0 0 800 275 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %%Trailer %%EOF \ No newline at end of file diff --git a/docs/logo/scm-manager_logo.jpg b/docs/logo/scm-manager_logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f518e3f2b22fd6bff3fa3a7ace5f7f6d613deb89 GIT binary patch literal 45667 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 bc)#@GfBp9UZ@~Yn!2ea?f3gA(eoy`fu!W$3Aq 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 e11;C2rZi<838oYu4 literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_img.jpg b/docs/logo/scm-manager_logo_img.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f2d3b35b66d5fda7a3b08f2ccbad39e12716042d GIT binary patch literal 34748 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 literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_img.png b/docs/logo/scm-manager_logo_img.png new file mode 100644 index 0000000000000000000000000000000000000000..f349fc5d22efffe15ecbc5bd8f3b1bb1411dc952 GIT binary patch literal 33758 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 literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_neg.jpg b/docs/logo/scm-manager_logo_neg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d6704d0e39f8a232a4c41f3789cbeb2f31b2bf8 GIT binary patch literal 47177 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%0Ag$~;I>ySYsrKLZ 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 literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_neg1.jpg b/docs/logo/scm-manager_logo_neg1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d776c6b6e67de60b873206279baaad91b5ff8fcb GIT binary patch literal 34398 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 aaz5*{TA{O6Nj|TIjUcS;t;iNW34a36kSve@ literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_pos1.jpg b/docs/logo/scm-manager_logo_pos1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fd2533c198c68353b4e978b6acb536fb4e6f09a2 GIT binary patch literal 34444 zcmeFYWmH_x(l0!OBv=R#fxm*5V;9bW$D zId<>+th?@q^L)EKYi8BnUG=N(s;=tZy?dT!pH>0*l5Un}0D!D40{|KDukv&Nz>;t_ zv33U_0A4=dHU$8lRuTVLI62w#v$EPcvKX1#8G~6&>}*)wjOxf2-y%(C3!(D?=@x9cToR zurqP~3r1E#h{D;~(v+V|f}M?*kDZf`Lrh9S3<%@{a*FbD@v%v80J+$B#d-e$@b7}p zkSwOpvh1P~Y<&%`;T|BZe&QBHPlpd<$yFFPAIFA&Jf&MwL)CCbgm&Bx2h!N*PUcUjYa zGuS`+{X8uH&$6t~jWIRhcd~SXfd4*1MIlaqry!PpjS_x{k*)c&IhaksW=75sCkkN+ z=yP-mvi>#rAePU(f200OjP>8Z{&xG{M(aN~pF`j;@}IHyeDTld1=~J{Ec7`xpAG>s z|1uy43mbq}OjhybU-r%GRnm~Y2Dlq5r!gmz zmh1l05#a6LVi#cg@c)(nXMz6_3!q-P z&nKV84cxYn?5|t9%kl<|SFZR@c%|BZSFP87yIV#se7K%or@%DiJakmWC2v)AW^RPR z@)HG_Q|^J1+mU$t-B&-U6m~c9te5-Sg^2P>IaZ4?7e!=jsNq!79rT1ikMnwhW)u{Z?1T#KeqY2(rAKOl!@Y|R# z5@w_$j0=Lv=`l*0gT>}zG+uaWDR)56-ZOWUUIx4u3`kH(*j&i|s93Zp6ObDUISEGSZtDCB{b+fkrS6&l2rgfar*>xdAM)+ID%)H4f z6>Y+^IqpMjZDPSY$@pLRC^r9Qvi}JZ0j@vNlM>vA#CUk5yfn6DkW|NLEzO-`5i6aZ zqpOt`%Q+ONsiGGjqE+M~HMG(|e-=-?O34ic>6ACiy=Eri84+0TjnX?1La)?_%|8BM zKW9PJGo3Fg7oYcL zjJ%5^w4E(1g6jv1RXW;zR!OD_!y$JGqr;7$g(P%EhIUsxR@M5F+Lkqn?-P=kTVQd8 zANs-Qz=|_zbFG4wF2hsX3op+flx@yjMt)j(zAe2t)X=}fwW_VLMz2zi35gYqf6eS{ z9#a#uB|A-0)tQ~|h~*Ti8r0rS+OkCn7l~?nHPP+*!R9NyJ~gR(7aOHnSA`t|h2p9U z5k!jH=?wFv9-WoewwR~{yCjFGxxPBaq`~k_|6Gy=#@U8p zN~#nBvuzbY{>WI166cAFtL$cJh3lW8kk2Ca{}Vp^7v?K=9|Ipov{A1^j15iYnK#=G z&_xkp@F{gvx^9gL)|6;7&EPLoW{pJ4>B=LsysYsRmg`a_CRAF)^_

}hHBCa` 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

28wbN&WdC<^|c{)t*K5lj?WLEbT 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 literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_pos1.png b/docs/logo/scm-manager_logo_pos1.png new file mode 100644 index 0000000000000000000000000000000000000000..b91141735848806ec1dcf37d8d54f1e05038df11 GIT binary patch literal 34079 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

k<;UTG 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 Date: Wed, 7 Aug 2019 16:46:01 +0200 Subject: [PATCH 057/135] added support information on admin overview page --- scm-ui/public/images/iconCommunitySupport.png | Bin 0 -> 3653 bytes .../public/images/iconEnterpriseSupport.png | Bin 0 -> 3928 bytes scm-ui/public/locales/de/admin.json | 11 ++- scm-ui/public/locales/en/admin.json | 13 +++- scm-ui/src/admin/containers/AdminDetails.js | 65 ++++++++++++++++-- 5 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 scm-ui/public/images/iconCommunitySupport.png create mode 100644 scm-ui/public/images/iconEnterpriseSupport.png diff --git a/scm-ui/public/images/iconCommunitySupport.png b/scm-ui/public/images/iconCommunitySupport.png new file mode 100644 index 0000000000000000000000000000000000000000..7a080903a0b29278563b9a435ba3f7e77d71fb2f GIT binary patch literal 3653 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 literal 0 HcmV?d00001 diff --git a/scm-ui/public/images/iconEnterpriseSupport.png b/scm-ui/public/images/iconEnterpriseSupport.png new file mode 100644 index 0000000000000000000000000000000000000000..0eadfaf85b63afbee2fe97a07a632486a8ddb5a4 GIT binary patch literal 3928 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 zqBh5sKontaktieren Sie das SCM-Manager Support Team bei unserem Entwicklungs-Partner Cloudogu für ein unverbindliches Angebot.", + "enterpriseLink": "https://cloudogu.com/de/scm-manager-enterprise/", + "enterpriseButton": "Enterprise Support anfragen" } }, "plugins": { diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index a5697ea4fe..86b3e788e0 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -6,8 +6,17 @@ "settingsNavLink": "Settings", "generalNavLink": "General" }, - "information": { - "currentAppVersion": "Current Application Version" + "info": { + "currentAppVersion": "Current Application Version", + "communityTitle": "Community Support", + "communityIconAlt": "Community Support Icon", + "communityInfo": "The Cloudogu support team is available for general questions about SCM-Manager, bug reporting and feature requests through our official channels.", + "communityButton": "Contact our team", + "enterpriseTitle": "Enterprise Support", + "enterpriseIconAlt": "Enterprise Support Icon", + "enterpriseInfo": "You need technical support for your company or you need a SCM-Manager plugin developed for you? We are happy to help you with your individual requirements for SCM-Manager use.
Contact the SCM-Manager Support Team at our development partner Cloudogu for a non-binding offer.", + "enterpriseLink": "https://cloudogu.com/en/scm-manager-enterprise/", + "enterpriseButton": "Request Enterprise Support" } }, "plugins": { diff --git a/scm-ui/src/admin/containers/AdminDetails.js b/scm-ui/src/admin/containers/AdminDetails.js index a7a135aa97..4b98a891e5 100644 --- a/scm-ui/src/admin/containers/AdminDetails.js +++ b/scm-ui/src/admin/containers/AdminDetails.js @@ -1,8 +1,15 @@ // @flow import React from "react"; import { connect } from "react-redux"; +import injectSheet from "react-jss"; import { translate } from "react-i18next"; -import { Loading, Title, Subtitle } from "@scm-manager/ui-components"; +import classNames from "classnames"; +import { + Loading, + Title, + Subtitle, + Image +} from "@scm-manager/ui-components"; import { getAppVersion } from "../../modules/indexResource"; type Props = { @@ -11,13 +18,23 @@ type Props = { version: string, - // context objects + // context props + classes: any, t: string => string }; +const styles = { + boxShadow: { + boxShadow: "0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 1px rgb(40, 177, 232)" + }, + boxTitle: { + fontWeight: "500 !important" + } +}; + class AdminDetails extends React.Component { render() { - const { t, loading } = this.props; + const { loading, classes, t } = this.props; if (loading) { return ; @@ -25,8 +42,46 @@ class AdminDetails extends React.Component { return ( <> - + <Title title={t("admin.info.currentAppVersion")} /> <Subtitle subtitle={this.props.version} /> + <div className={classNames("box", classes.boxShadow)}> + <article className="media"> + <div className="media-left"> + <figure className="image is-64x64"> + <Image + src="/images/iconCommunitySupport.png" + alt={t("admin.info.communityIconAlt")} + /> + </figure> + </div> + <div className="media-content"> + <div className="content"> + <h3 className={classes.boxTitle}>{t("admin.info.communityTitle")}</h3> + <p>{t("admin.info.communityInfo")}</p> + <a className="button is-info is-pulled-right" href="https://scm-manager.org/support/">{t("admin.info.communityButton")}</a> + </div> + </div> + </article> + </div> + <div className={classNames("box", classes.boxShadow)}> + <article className="media"> + <div className="media-left"> + <figure className="image is-64x64"> + <Image + src="/images/iconEnterpriseSupport.png" + alt={t("admin.info.enterpriseIconAlt")} + /> + </figure> + </div> + <div className="media-content"> + <div className="content"> + <h3 className={classes.boxTitle}>{t("admin.info.enterpriseTitle")}</h3> + <p>{t("admin.info.enterpriseInfo")}</p> + <a className="button is-info is-pulled-right" href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a> + </div> + </div> + </article> + </div> </> ); } @@ -39,4 +94,4 @@ const mapStateToProps = (state: any) => { }; }; -export default connect(mapStateToProps)(translate("admin")(AdminDetails)); +export default connect(mapStateToProps)(injectSheet(styles)(translate("admin")(AdminDetails))); From 454453ea4cc4634fdea52455c70855dff1e5d009 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Wed, 7 Aug 2019 17:01:29 +0200 Subject: [PATCH 058/135] added second translation for partner information to not have to change i18n.options.react to adapt behaviour or defining var --- scm-ui/public/locales/de/admin.json | 3 ++- scm-ui/public/locales/en/admin.json | 3 ++- scm-ui/src/admin/containers/AdminDetails.js | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 6d3371c1cd..c03dbf9b01 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -14,7 +14,8 @@ "communityButton": "Unser Team kontaktieren", "enterpriseTitle": "Enterprise Support", "enterpriseIconAlt": "Enterprise Support Icon", - "enterpriseInfo": "Sie benötigen technische Unterstützung für Ihr Unternehmen oder haben Bedarf an einem für Sie entwickelten SCM-Manager Plugin? Gerne helfen wir Ihnen bei Ihren individuellen Anforderungen für die SCM-Manager Nutzung weiter.<br /><strong>Kontaktieren Sie das SCM-Manager Support Team bei unserem Entwicklungs-Partner Cloudogu für ein unverbindliches Angebot.</strong>", + "enterpriseInfo": "Sie benötigen technische Unterstützung für Ihr Unternehmen oder haben Bedarf an einem für Sie entwickelten SCM-Manager Plugin? Gerne helfen wir Ihnen bei Ihren individuellen Anforderungen für die SCM-Manager Nutzung weiter.", + "enterprisePartner": "Kontaktieren Sie das SCM-Manager Support Team bei unserem Entwicklungs-Partner Cloudogu für ein unverbindliches Angebot.", "enterpriseLink": "https://cloudogu.com/de/scm-manager-enterprise/", "enterpriseButton": "Enterprise Support anfragen" } diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 86b3e788e0..21e3ed4e84 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -14,7 +14,8 @@ "communityButton": "Contact our team", "enterpriseTitle": "Enterprise Support", "enterpriseIconAlt": "Enterprise Support Icon", - "enterpriseInfo": "You need technical support for your company or you need a SCM-Manager plugin developed for you? We are happy to help you with your individual requirements for SCM-Manager use.<br /><strong>Contact the SCM-Manager Support Team at our development partner Cloudogu for a non-binding offer.</strong>", + "enterpriseInfo": "You need technical support for your company or you need a SCM-Manager plugin developed for you? We are happy to help you with your individual requirements for SCM-Manager use.", + "enterprisePartner": "Contact the SCM-Manager Support Team at our development partner Cloudogu for a non-binding offer.", "enterpriseLink": "https://cloudogu.com/en/scm-manager-enterprise/", "enterpriseButton": "Request Enterprise Support" } diff --git a/scm-ui/src/admin/containers/AdminDetails.js b/scm-ui/src/admin/containers/AdminDetails.js index 4b98a891e5..99b448cda3 100644 --- a/scm-ui/src/admin/containers/AdminDetails.js +++ b/scm-ui/src/admin/containers/AdminDetails.js @@ -76,7 +76,7 @@ class AdminDetails extends React.Component<Props> { <div className="media-content"> <div className="content"> <h3 className={classes.boxTitle}>{t("admin.info.enterpriseTitle")}</h3> - <p>{t("admin.info.enterpriseInfo")}</p> + <p>{t("admin.info.enterpriseInfo")}<br /><strong>{t("admin.info.enterprisePartner")}</strong></p> <a className="button is-info is-pulled-right" href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a> </div> </div> From 2dfeabfd0e9feb312f9bf422a37336ddfeff0d5c Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Wed, 7 Aug 2019 17:21:46 +0200 Subject: [PATCH 059/135] small typo key fix --- scm-ui/public/locales/de/admin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index c03dbf9b01..5744ad6c6d 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -6,7 +6,7 @@ "settingsNavLink": "Einstellungen", "generalNavLink": "Generell" }, - "information": { + "info": { "currentAppVersion": "Aktuelle Software-Versionsnummer", "communityTitle": "Community Support", "communityIconAlt": "Community Support Icon", From 74df156ba0a7c0ba69efee18d2a86e1c6c414e92 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Thu, 8 Aug 2019 10:40:38 +0200 Subject: [PATCH 060/135] open links in new tab / small ui fixes --- scm-ui/src/admin/containers/AdminDetails.js | 23 ++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/scm-ui/src/admin/containers/AdminDetails.js b/scm-ui/src/admin/containers/AdminDetails.js index 99b448cda3..e439077731 100644 --- a/scm-ui/src/admin/containers/AdminDetails.js +++ b/scm-ui/src/admin/containers/AdminDetails.js @@ -1,16 +1,11 @@ // @flow import React from "react"; -import { connect } from "react-redux"; +import {connect} from "react-redux"; import injectSheet from "react-jss"; -import { translate } from "react-i18next"; +import {translate} from "react-i18next"; import classNames from "classnames"; -import { - Loading, - Title, - Subtitle, - Image -} from "@scm-manager/ui-components"; -import { getAppVersion } from "../../modules/indexResource"; +import {Image, Loading, Subtitle, Title} from "@scm-manager/ui-components"; +import {getAppVersion} from "../../modules/indexResource"; type Props = { loading: boolean, @@ -25,7 +20,7 @@ type Props = { const styles = { boxShadow: { - boxShadow: "0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 1px rgb(40, 177, 232)" + boxShadow: "0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 2px rgb(40, 177, 232, 0.2)" }, boxTitle: { fontWeight: "500 !important" @@ -47,7 +42,7 @@ class AdminDetails extends React.Component<Props> { <div className={classNames("box", classes.boxShadow)}> <article className="media"> <div className="media-left"> - <figure className="image is-64x64"> + <figure className="image is-96x96"> <Image src="/images/iconCommunitySupport.png" alt={t("admin.info.communityIconAlt")} @@ -58,7 +53,7 @@ class AdminDetails extends React.Component<Props> { <div className="content"> <h3 className={classes.boxTitle}>{t("admin.info.communityTitle")}</h3> <p>{t("admin.info.communityInfo")}</p> - <a className="button is-info is-pulled-right" href="https://scm-manager.org/support/">{t("admin.info.communityButton")}</a> + <a className="button is-info is-pulled-right" target="_blank" href="https://scm-manager.org/support/">{t("admin.info.communityButton")}</a> </div> </div> </article> @@ -66,7 +61,7 @@ class AdminDetails extends React.Component<Props> { <div className={classNames("box", classes.boxShadow)}> <article className="media"> <div className="media-left"> - <figure className="image is-64x64"> + <figure className="image is-96x96"> <Image src="/images/iconEnterpriseSupport.png" alt={t("admin.info.enterpriseIconAlt")} @@ -77,7 +72,7 @@ class AdminDetails extends React.Component<Props> { <div className="content"> <h3 className={classes.boxTitle}>{t("admin.info.enterpriseTitle")}</h3> <p>{t("admin.info.enterpriseInfo")}<br /><strong>{t("admin.info.enterprisePartner")}</strong></p> - <a className="button is-info is-pulled-right" href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a> + <a className="button is-info is-pulled-right is-normal" target="_blank" href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a> </div> </div> </article> From 358f7475e722790364461fe60bf49b8ae6f90d4f Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Thu, 8 Aug 2019 11:12:46 +0200 Subject: [PATCH 061/135] change translations --- scm-ui/public/locales/de/admin.json | 6 +++--- scm-ui/public/locales/en/admin.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 5744ad6c6d..54ae1ab91a 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -10,12 +10,12 @@ "currentAppVersion": "Aktuelle Software-Versionsnummer", "communityTitle": "Community Support", "communityIconAlt": "Community Support Icon", - "communityInfo": "Das Cloudogu Support-Team steht für allgemeine Fragen rund um SCM-Manager, die Meldung von Fehlern sowie Anfragen von Features gerne für Sie über unsere offiziellen Kanäle bereit.", + "communityInfo": "Das SCM-Manager Support-Team steht für allgemeine Fragen, die Meldung von Fehlern sowie Anfragen für Features gerne für Sie über die offiziellen Kanäle bereit.", "communityButton": "Unser Team kontaktieren", "enterpriseTitle": "Enterprise Support", "enterpriseIconAlt": "Enterprise Support Icon", - "enterpriseInfo": "Sie benötigen technische Unterstützung für Ihr Unternehmen oder haben Bedarf an einem für Sie entwickelten SCM-Manager Plugin? Gerne helfen wir Ihnen bei Ihren individuellen Anforderungen für die SCM-Manager Nutzung weiter.", - "enterprisePartner": "Kontaktieren Sie das SCM-Manager Support Team bei unserem Entwicklungs-Partner Cloudogu für ein unverbindliches Angebot.", + "enterpriseInfo": "Sie benötigen Unterstützung bei der Integration von SCM-Manager in Ihre Prozesse, bei der Anpassung des Tools auf Ihre Anforderungen oder einfach ein Service Level Agreement (SLA)?", + "enterprisePartner": "Treten Sie mit unserem Entwicklungs-Partner Cloudogu in Kontakt! Das Team freut sich auf den Austausch über Ihre individuellen Anforderungen und erstellt Ihnen gerne ein maßgeschneidertes Angebot.", "enterpriseLink": "https://cloudogu.com/de/scm-manager-enterprise/", "enterpriseButton": "Enterprise Support anfragen" } diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 21e3ed4e84..2402f21423 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -10,12 +10,12 @@ "currentAppVersion": "Current Application Version", "communityTitle": "Community Support", "communityIconAlt": "Community Support Icon", - "communityInfo": "The Cloudogu support team is available for general questions about SCM-Manager, bug reporting and feature requests through our official channels.", + "communityInfo": "Contact the SCM-Manager support team for questions about SCM-Manager, to report bugs or to request features through the official channels.", "communityButton": "Contact our team", "enterpriseTitle": "Enterprise Support", "enterpriseIconAlt": "Enterprise Support Icon", - "enterpriseInfo": "You need technical support for your company or you need a SCM-Manager plugin developed for you? We are happy to help you with your individual requirements for SCM-Manager use.", - "enterprisePartner": "Contact the SCM-Manager Support Team at our development partner Cloudogu for a non-binding offer.", + "enterpriseInfo": "You require support with the integration of SCM-Manager into your processes, with the customization of the tool or simply a service level agreement (SLA)?", + "enterprisePartner": "Contact our development partner Cloudogu! Their team is looking forward to discussing your individual requirements with you and will be more than happy to give you a quote.", "enterpriseLink": "https://cloudogu.com/en/scm-manager-enterprise/", "enterpriseButton": "Request Enterprise Support" } From 50499adbe4e3fe1f69da1fa4492d04fe8e3063fd Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Fri, 9 Aug 2019 08:35:14 +0200 Subject: [PATCH 062/135] fixed iconSize and imagePadding --- scm-ui/src/admin/containers/AdminDetails.js | 43 +++++++++++---------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/scm-ui/src/admin/containers/AdminDetails.js b/scm-ui/src/admin/containers/AdminDetails.js index e439077731..b257d04554 100644 --- a/scm-ui/src/admin/containers/AdminDetails.js +++ b/scm-ui/src/admin/containers/AdminDetails.js @@ -24,55 +24,56 @@ const styles = { }, boxTitle: { fontWeight: "500 !important" + }, + imagePadding: { + padding: "0.5rem 0.5rem" } }; class AdminDetails extends React.Component<Props> { render() { - const { loading, classes, t } = this.props; + const {loading, classes, t} = this.props; if (loading) { - return <Loading />; + return <Loading/>; } return ( <> - <Title title={t("admin.info.currentAppVersion")} /> - <Subtitle subtitle={this.props.version} /> + <Title title={t("admin.info.currentAppVersion")}/> + <Subtitle subtitle={this.props.version}/> <div className={classNames("box", classes.boxShadow)}> <article className="media"> - <div className="media-left"> - <figure className="image is-96x96"> - <Image - src="/images/iconCommunitySupport.png" - alt={t("admin.info.communityIconAlt")} - /> - </figure> + <div className={classNames("media-left", classes.imagePadding)}> + <Image + src="/images/iconCommunitySupport.png" + alt={t("admin.info.communityIconAlt")} + /> </div> <div className="media-content"> <div className="content"> <h3 className={classes.boxTitle}>{t("admin.info.communityTitle")}</h3> <p>{t("admin.info.communityInfo")}</p> - <a className="button is-info is-pulled-right" target="_blank" href="https://scm-manager.org/support/">{t("admin.info.communityButton")}</a> + <a className="button is-info is-pulled-right" target="_blank" + href="https://scm-manager.org/support/">{t("admin.info.communityButton")}</a> </div> </div> </article> </div> <div className={classNames("box", classes.boxShadow)}> <article className="media"> - <div className="media-left"> - <figure className="image is-96x96"> - <Image - src="/images/iconEnterpriseSupport.png" - alt={t("admin.info.enterpriseIconAlt")} - /> - </figure> + <div className={classNames("media-left", classes.imagePadding)}> + <Image + src="/images/iconEnterpriseSupport.png" + alt={t("admin.info.enterpriseIconAlt")} + /> </div> <div className="media-content"> <div className="content"> <h3 className={classes.boxTitle}>{t("admin.info.enterpriseTitle")}</h3> - <p>{t("admin.info.enterpriseInfo")}<br /><strong>{t("admin.info.enterprisePartner")}</strong></p> - <a className="button is-info is-pulled-right is-normal" target="_blank" href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a> + <p>{t("admin.info.enterpriseInfo")}<br/><strong>{t("admin.info.enterprisePartner")}</strong></p> + <a className="button is-info is-pulled-right is-normal" target="_blank" + href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a> </div> </div> </article> From 3f6f45cebccf71c4775bc6771b4d5e82da6c256d Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Fri, 9 Aug 2019 08:40:31 +0200 Subject: [PATCH 063/135] adjust imagePadding --- scm-ui/src/admin/containers/AdminDetails.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/src/admin/containers/AdminDetails.js b/scm-ui/src/admin/containers/AdminDetails.js index b257d04554..803524abed 100644 --- a/scm-ui/src/admin/containers/AdminDetails.js +++ b/scm-ui/src/admin/containers/AdminDetails.js @@ -26,7 +26,7 @@ const styles = { fontWeight: "500 !important" }, imagePadding: { - padding: "0.5rem 0.5rem" + padding: "0.2rem 0.4rem" } }; From 3822d2c4c35aeec918e97e6efc3cdfa5c5974919 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Fri, 9 Aug 2019 06:46:37 +0000 Subject: [PATCH 064/135] Close branch feature/admin_support From c58788e1e5572e015c1e80e5889384611cd5f281 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Mon, 12 Aug 2019 11:16:47 +0200 Subject: [PATCH 065/135] add avatarUrl / fix conditions_os to list of string --- .../sonia/scm/plugin/PluginInformation.java | 346 +++--------------- .../packages/ui-types/src/Plugin.js | 3 +- .../admin/plugins/components/PluginAvatar.js | 8 +- .../scm/api/v2/resources/PluginCenterDto.java | 4 +- .../v2/resources/PluginCenterDtoMapper.java | 3 +- .../sonia/scm/api/v2/resources/PluginDto.java | 2 +- .../scm/api/v2/resources/PluginDtoMapper.java | 4 +- .../resources/PluginCenterDtoMapperTest.java | 37 +- 8 files changed, 88 insertions(+), 319 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index de0a3ca1e9..99ad1e82e8 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + * <p> * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * <p> * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * <p> * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,13 +24,11 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + * <p> * http://bitbucket.org/sdorra/scm-manager - * */ - package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- @@ -39,6 +37,8 @@ import com.github.sdorra.ssp.PermissionObject; import com.github.sdorra.ssp.StaticPermissions; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import lombok.Getter; +import lombok.Setter; import sonia.scm.Validateable; import sonia.scm.util.Util; @@ -46,43 +46,38 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; -import java.util.Map; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ @StaticPermissions( - value = "plugin", - generatedClass = "PluginPermissions", + value = "plugin", + generatedClass = "PluginPermissions", permissions = {}, - globalPermissions = { "read", "manage" }, + globalPermissions = {"read", "manage"}, custom = true, customGlobal = true ) @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "plugin-information") -public class PluginInformation - implements PermissionObject, Validateable, Cloneable, Serializable -{ +@Getter +@Setter +public class PluginInformation implements PermissionObject, Validateable, Cloneable, Serializable { - /** Field description */ private static final long serialVersionUID = 461382048865977206L; - //~--- methods -------------------------------------------------------------- + private String author; + private String category; + private PluginCondition condition; + private String description; + private String name; + private PluginState state; + private String version; + private String avatarUrl; - /** - * Method description - * - * - * @return - * - * @since 1.11 - */ @Override - public PluginInformation clone() - { + public PluginInformation clone() { PluginInformation clone = new PluginInformation(); clone.setName(name); clone.setAuthor(author); @@ -90,33 +85,22 @@ public class PluginInformation clone.setDescription(description); clone.setState(state); clone.setVersion(version); + clone.setAvatarUrl(avatarUrl); - if (condition != null) - { + if (condition != null) { clone.setCondition(condition.clone()); } return clone; } - /** - * Method description - * - * - * @param obj - * - * @return - */ @Override - public boolean equals(Object obj) - { - if (obj == null) - { + public boolean equals(Object obj) { + if (obj == null) { return false; } - if (getClass() != obj.getClass()) - { + if (getClass() != obj.getClass()) { return false; } @@ -125,276 +109,54 @@ public class PluginInformation //J- return Objects.equal(author, other.author) - && Objects.equal(category, other.category) - && Objects.equal(condition, other.condition) - && Objects.equal(description, other.description) - && Objects.equal(name, other.name) - && Objects.equal(state, other.state) - && Objects.equal(version, other.version); + && Objects.equal(category, other.category) + && Objects.equal(condition, other.condition) + && Objects.equal(description, other.description) + && Objects.equal(name, other.name) + && Objects.equal(state, other.state) + && Objects.equal(version, other.version) + && Objects.equal(avatarUrl, other.avatarUrl); //J+ } - /** - * Method description - * - * - * @return - */ @Override - public int hashCode() - { + public int hashCode() { return Objects.hashCode(author, category, condition, - description, name, state, version); + description, name, state, version, avatarUrl); } - /** - * Method description - * - * - * @return - */ @Override - public String toString() - { + public String toString() { //J- return MoreObjects.toStringHelper(this) - .add("author", author) - .add("category", category) - .add("condition", condition) - .add("description", description) - .add("name", name) - .add("state", state) - .add("version", version) - .toString(); + .add("author", author) + .add("category", category) + .add("condition", condition) + .add("description", description) + .add("name", name) + .add("state", state) + .add("version", version) + .add("avatarUrl", avatarUrl) + .toString(); //J+ } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public String getAuthor() - { - return author; - } - - /** - * Method description - * - * - * @return - */ - public String getCategory() - { - return category; - } - - /** - * Method description - * - * - * @return - */ - public PluginCondition getCondition() - { - return condition; - } - - /** - * Method description - * - * - * @return - */ - public String getDescription() - { - return description; - } - - - /** - * Method description - * - * - * @return - */ @Override - public String getId() - { + public String getId() { return getName(true); } - /** - * Method description - * - * - * @param withVersion - * - * @return - * @since 1.21 - */ - public String getName(boolean withVersion) - { + public String getName(boolean withVersion) { StringBuilder id = new StringBuilder(name); - if (withVersion) - { + if (withVersion) { id.append(":").append(version); } - return id.toString(); } - /** - * Method description - * - * - * @return - */ - public String getName() - { - return name; - } - - /** - * Method description - * - * - * @return - */ - public PluginState getState() - { - return state; - } - - /** - * Method description - * - * - * @return - */ - public String getVersion() - { - return version; - } - - /** - * Method description - * - * - * @return - */ @Override - public boolean isValid() - { + public boolean isValid() { return Util.isNotEmpty(name) && Util.isNotEmpty(version); } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param author - */ - public void setAuthor(String author) - { - this.author = author; - } - - /** - * Method description - * - * - * @param category - */ - public void setCategory(String category) - { - this.category = category; - } - - /** - * Method description - * - * - * @param condition - */ - public void setCondition(PluginCondition condition) - { - this.condition = condition; - } - - /** - * Method description - * - * - * @param description - */ - public void setDescription(String description) - { - this.description = description; - } - - - /** - * Method description - * - * - * @param name - */ - public void setName(String name) - { - this.name = name; - } - - /** - * Method description - * - * - * @param state - */ - public void setState(PluginState state) - { - this.state = state; - } - - /** - * Method description - * - * - * @param version - */ - public void setVersion(String version) - { - this.version = version; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private String author; - - /** Field description */ - private String category; - - /** Field description */ - private PluginCondition condition; - - /** Field description */ - private String description; - - /** Field description */ - private String name; - - /** Field description */ - private PluginState state; - - /** Field description */ - private String version; - } diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index bb9c5e7d88..72e4908a54 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -1,11 +1,12 @@ //@flow -import type { Collection, Links } from "./hal"; +import type {Collection, Links} from "./hal"; export type Plugin = { name: string, type: string, version: string, author: string, + avatarUrl: string, description?: string, _links: Links }; diff --git a/scm-ui/src/admin/plugins/components/PluginAvatar.js b/scm-ui/src/admin/plugins/components/PluginAvatar.js index 10408f14bd..42a1fd732b 100644 --- a/scm-ui/src/admin/plugins/components/PluginAvatar.js +++ b/scm-ui/src/admin/plugins/components/PluginAvatar.js @@ -1,8 +1,8 @@ //@flow import React from "react"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import type { Plugin } from "@scm-manager/ui-types"; -import { Image } from "@scm-manager/ui-components"; +import {ExtensionPoint} from "@scm-manager/ui-extensions"; +import type {Plugin} from "@scm-manager/ui-types"; +import {Image} from "@scm-manager/ui-components"; type Props = { plugin: Plugin @@ -14,7 +14,7 @@ export default class PluginAvatar extends React.Component<Props> { return ( <p className="image is-64x64"> <ExtensionPoint name="plugins.plugin-avatar" props={{ plugin }}> - <Image src="/images/blib.jpg" alt="Logo" /> + <Image src={plugin.avatarUrl ? plugin.avatarUrl : "/images/blib.jpg"} alt="Logo" /> </ExtensionPoint> </p> ); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java index 2b72e7fddc..423a0ba0d2 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java @@ -3,7 +3,6 @@ package sonia.scm.api.v2.resources; import com.google.common.collect.ImmutableList; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.Setter; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -51,6 +50,7 @@ public final class PluginCenterDto implements Serializable { private String category; private String version; private String author; + private String avatarUrl; private String sha256; @XmlElement(name = "conditions") @@ -69,7 +69,7 @@ public final class PluginCenterDto implements Serializable { @AllArgsConstructor public static class Condition { - private String os; + private List<String> os; private String arch; private String minVersion; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java index 4ec5a48667..3a4e8a1947 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java @@ -3,7 +3,6 @@ package sonia.scm.api.v2.resources; import sonia.scm.plugin.PluginCondition; import sonia.scm.plugin.PluginInformation; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -24,7 +23,7 @@ public class PluginCenterDtoMapper { if (plugin.getConditions() != null) { PluginCenterDto.Condition condition = plugin.getConditions(); - pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), Collections.singletonList(condition.getOs()), condition.getArch())); + pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), condition.getOs(), condition.getArch())); } pluginInformationSet.add(pluginInformation); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index a35c3e848d..75386aed63 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -3,7 +3,6 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; - import lombok.NoArgsConstructor; import lombok.Setter; @@ -16,6 +15,7 @@ public class PluginDto extends HalRepresentation { private String category; private String version; private String author; + private String avatarUrl; private String description; public PluginDto(Links links) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 9604ccbcc0..020e706e43 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -4,9 +4,10 @@ import de.otto.edison.hal.Links; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginState; import sonia.scm.plugin.PluginWrapper; + import javax.inject.Inject; -import static de.otto.edison.hal.Link.*; +import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; public class PluginDtoMapper { @@ -43,6 +44,7 @@ public class PluginDtoMapper { pluginDto.setVersion(pluginInformation.getVersion()); pluginDto.setAuthor(pluginInformation.getAuthor()); pluginDto.setDescription(pluginInformation.getDescription()); + pluginDto.setAvatarUrl(pluginInformation.getAvatarUrl()); return pluginDto; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java index ecbc44a1ed..1175526f75 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java @@ -12,7 +12,9 @@ import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; -import static sonia.scm.api.v2.resources.PluginCenterDto.*; +import static sonia.scm.api.v2.resources.PluginCenterDto.Condition; +import static sonia.scm.api.v2.resources.PluginCenterDto.Dependency; +import static sonia.scm.api.v2.resources.PluginCenterDto.Plugin; class PluginCenterDtoMapperTest { @@ -32,8 +34,9 @@ class PluginCenterDtoMapperTest { "Travel", "2.0.0", "trillian", + "http://avatar.url", "555000444", - new Condition("linux", "amd64","2.0.0"), + new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), new Dependency("scm-review-plugin"), new HashMap<>()); @@ -44,7 +47,7 @@ class PluginCenterDtoMapperTest { assertThat(result.getVersion()).isEqualTo(plugin.getVersion()); assertThat(result.getCondition().getArch()).isEqualTo(plugin.getConditions().getArch()); assertThat(result.getCondition().getMinVersion()).isEqualTo(plugin.getConditions().getMinVersion()); - assertThat(result.getCondition().getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs()); + assertThat(result.getCondition().getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next()); assertThat(result.getDescription()).isEqualTo(plugin.getDescription()); assertThat(result.getName()).isEqualTo(plugin.getName()); } @@ -52,26 +55,28 @@ class PluginCenterDtoMapperTest { @Test void shouldMapMultiplePlugins() { Plugin plugin1 = new Plugin( - "scm-hitchhiker-plugin", - "SCM Hitchhiker Plugin", - "plugin for hitchhikers", - "Travel", - "2.0.0", - "dent", - "555000444", - new Condition("linux", "amd64","2.0.0"), - new Dependency("scm-review-plugin"), - new HashMap<>()); - - Plugin plugin2 = new Plugin( "scm-review-plugin", "SCM Hitchhiker Plugin", "plugin for hitchhikers", "Travel", "2.1.0", "trillian", + "https://avatar.url", "12345678aa", - new Condition("linux", "amd64","2.0.0"), + new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), + new Dependency("scm-review-plugin"), + new HashMap<>()); + + Plugin plugin2 = new Plugin( + "scm-hitchhiker-plugin", + "SCM Hitchhiker Plugin", + "plugin for hitchhikers", + "Travel", + "2.0.0", + "dent", + "http://avatar.url", + "555000444", + new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), new Dependency("scm-review-plugin"), new HashMap<>()); From 900e52ad0e95751669608a438f0e5af4aab8d638 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Mon, 12 Aug 2019 13:18:30 +0200 Subject: [PATCH 066/135] update dtd --- docs/dtd/plugin/2.0.0-01.dtd | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/docs/dtd/plugin/2.0.0-01.dtd b/docs/dtd/plugin/2.0.0-01.dtd index eec149f3e8..a4f5a2d831 100644 --- a/docs/dtd/plugin/2.0.0-01.dtd +++ b/docs/dtd/plugin/2.0.0-01.dtd @@ -29,47 +29,23 @@ <!ELEMENT scm-version (#PCDATA)> <!--- contains informations of the plugin for the plugin backend --> -<!ELEMENT information (author|artifactId|category|tags|description|groupId|name|screenshots|url|version|wiki)*> +<!ELEMENT information (author|category|description|name|version)*> <!--- plugin author --> <!ELEMENT author (#PCDATA)> -<!--- maven artifact id --> -<!ELEMENT artifactId (#PCDATA)> - <!--- category of the plugin --> <!ELEMENT category (#PCDATA)> -<!--- tags of the plugin --> -<!ELEMENT tags (tag)*> - -<!--- single tag --> -<!ELEMENT tag (#PCDATA)> - <!--- description of the plugin --> <!ELEMENT description (#PCDATA)> -<!--- maven groupId id --> -<!ELEMENT groupId (#PCDATA)> - <!--- name of the plugin or the name of the os condition --> <!ELEMENT name (#PCDATA)> -<!--- contains screenshots of the plugin --> -<!ELEMENT screenshots (screenshot)*> - -<!--- single screenshot of the plugin --> -<!ELEMENT screenshot (#PCDATA)> - -<!--- the url of the plugin homepage --> -<!ELEMENT url (#PCDATA)> - <!--- the current version of the plugin --> <!ELEMENT version (#PCDATA)> -<!--- the url of a wiki page --> -<!ELEMENT wiki (#PCDATA)> - <!--- true if the plugin should load child classes first, the default is false --> <!ELEMENT child-first-classloader (#PCDATA)> @@ -121,4 +97,4 @@ <!ELEMENT event (#PCDATA)> <!--- extension point --> -<!ELEMENT extension-point (class|description)*> \ No newline at end of file +<!ELEMENT extension-point (class|description)*> From a2bff1fc5450a5cdfe9f2804feac114d3a8aacf6 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Mon, 12 Aug 2019 14:24:08 +0200 Subject: [PATCH 067/135] fix pointer = cursor --- scm-ui/src/admin/plugins/components/PluginEntry.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index b901cc0cfb..047ce5492e 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -1,8 +1,8 @@ //@flow import React from "react"; import injectSheet from "react-jss"; -import type { Plugin } from "@scm-manager/ui-types"; -import { CardColumn } from "@scm-manager/ui-components"; +import type {Plugin} from "@scm-manager/ui-types"; +import {CardColumn} from "@scm-manager/ui-components"; import PluginAvatar from "./PluginAvatar"; type Props = { @@ -14,7 +14,7 @@ type Props = { const styles = { link: { - pointerEvents: "all" + pointerEvents: "cursor" } }; From 2120a4ee029d9362e7ee88111e4ae632a7dad17b Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 13 Aug 2019 08:23:37 +0200 Subject: [PATCH 068/135] implemented ui for login information --- scm-ui/public/locales/de/commons.json | 6 +- scm-ui/public/locales/en/commons.json | 7 +- scm-ui/src/components/InfoBox.js | 118 ++++++++++++++++++++++++ scm-ui/src/components/InfoItem.js | 8 ++ scm-ui/src/components/LoginForm.js | 120 ++++++++++++++++++++++++ scm-ui/src/components/LoginInfo.js | 54 +++++++++++ scm-ui/src/containers/Login.js | 127 +++++--------------------- 7 files changed, 332 insertions(+), 108 deletions(-) create mode 100644 scm-ui/src/components/InfoBox.js create mode 100644 scm-ui/src/components/InfoItem.js create mode 100644 scm-ui/src/components/LoginForm.js create mode 100644 scm-ui/src/components/LoginInfo.js diff --git a/scm-ui/public/locales/de/commons.json b/scm-ui/public/locales/de/commons.json index 3964863f46..cdbedd8b6b 100644 --- a/scm-ui/public/locales/de/commons.json +++ b/scm-ui/public/locales/de/commons.json @@ -5,7 +5,11 @@ "logo-alt": "SCM-Manager", "username-placeholder": "Benutzername", "password-placeholder": "Passwort", - "submit": "Anmelden" + "submit": "Anmelden", + "plugin": "Plugin", + "feature": "Feature", + "tip": "Tipp", + "loading": "Lade Daten ..." }, "logout": { "error": { diff --git a/scm-ui/public/locales/en/commons.json b/scm-ui/public/locales/en/commons.json index c57ef700ef..181fdc975c 100644 --- a/scm-ui/public/locales/en/commons.json +++ b/scm-ui/public/locales/en/commons.json @@ -5,7 +5,12 @@ "logo-alt": "SCM-Manager", "username-placeholder": "Your Username", "password-placeholder": "Your Password", - "submit": "Login" + "submit": "Login", + "plugin": "Plugin", + "feature": "Feature", + "tip": "Tip", + "loading": "Loading ...", + "error": "Error" }, "logout": { "error": { diff --git a/scm-ui/src/components/InfoBox.js b/scm-ui/src/components/InfoBox.js new file mode 100644 index 0000000000..95d487a210 --- /dev/null +++ b/scm-ui/src/components/InfoBox.js @@ -0,0 +1,118 @@ +//@flow +import * as React from "react"; +import { ErrorNotification } from "@scm-manager/ui-components"; +import injectSheet from "react-jss"; +import classNames from "classnames"; +import { translate } from "react-i18next"; +import type { InfoItem } from "./InfoItem"; + +const styles = { + image: { + display: "flex", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", + width: 160, + height: 160 + }, + icon: { + color: "#bff1e6" + }, + label: { + marginTop: "0.5em" + }, + content: { + marginLeft: "1.5em" + }, + link: { + width: "60%", + height: "200px", + position: "absolute", + cursor: "pointer", + zIndex: 20 + }, + "@media (max-width: 768px)": { + link: { + width: "100%", + } + } +}; + +type Props = { + type: "plugin" | "feature", + item?: InfoItem, + error?: Error, + + // context props + classes: any, + t: string => string +}; + +class InfoBox extends React.Component<Props> { + + renderBody = () => { + const { item, error, t } = this.props; + + const bodyClasses = classNames("media-content", "content", this.props.classes.content); + + if (error) { + return ( + <div className={bodyClasses}> + <h4>{t("login.error")}</h4> + <ErrorNotification error={error}/> + </div> + ); + } + + const title = item ? item.title : t("login.loading"); + const summary = item ? item.summary : t("login.loading"); + + + return ( + <div className={bodyClasses}> + <h4> + <a href={this.createHref()}>{title}</a> + </h4> + <p>{summary}</p> + </div> + ); + + }; + + createHref = () => { + const { item } = this.props; + return item ? item._links.self.href : "#"; + }; + + createLink = () => { + const { classes } = this.props; + // eslint-disable-next-line jsx-a11y/anchor-has-content + return <a href={this.createHref()} className={classes.link} />; + }; + + render() { + const { type, classes, t } = this.props; + const icon = type === "plugin" ? "puzzle-piece" : "star"; + return ( + <> + {this.createLink()} + <div className="box media"> + <figure className="media-left"> + <div + className={classNames("image", "box", "has-background-info", "has-text-white", "has-text-weight-bold", classes.image)}> + <i className={classNames("fas", "fa-" + icon, "fa-2x", classes.icon)}/> + <div className={classNames("is-size-4", classes.label)}>{t("login." + type)}</div> + <div className={classNames("is-size-4")}>{t("login.tip")}</div> + </div> + </figure> + {this.renderBody()} + </div> + </> + ); + } + +} + +export default injectSheet(styles)(translate("commons")(InfoBox)); + + diff --git a/scm-ui/src/components/InfoItem.js b/scm-ui/src/components/InfoItem.js new file mode 100644 index 0000000000..b947bd3fce --- /dev/null +++ b/scm-ui/src/components/InfoItem.js @@ -0,0 +1,8 @@ +// @flow +import type { Link } from "@scm-manager/ui-types"; + +export type InfoItem = { + title: string, + summary: string, + _links: {[string]: Link} +}; diff --git a/scm-ui/src/components/LoginForm.js b/scm-ui/src/components/LoginForm.js new file mode 100644 index 0000000000..c1ad8c0ea5 --- /dev/null +++ b/scm-ui/src/components/LoginForm.js @@ -0,0 +1,120 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import { Image, ErrorNotification, InputField, SubmitButton, UnauthorizedError } from "@scm-manager/ui-components"; +import classNames from "classnames"; +import injectSheet from "react-jss"; + +const styles = { + avatar: { + marginTop: "-70px", + paddingBottom: "20px" + }, + avatarImage: { + border: "1px solid lightgray", + padding: "5px", + background: "#fff", + borderRadius: "50%", + width: "128px", + height: "128px" + }, + avatarSpacing: { + marginTop: "5rem" + } +}; + +type Props = { + error?: Error, + loading: boolean, + login: (username: string, password: string) => void, + + // context props + t: string => string, + classes: any +}; + +type State = { + username: string, + password: string +}; + +class LoginForm extends React.Component<Props, State> { + + constructor(props: Props) { + super(props); + this.state = { username: "", password: "" }; + } + + handleSubmit = (event: Event) => { + event.preventDefault(); + if (this.isValid()) { + this.props.login( + this.state.username, + this.state.password + ); + } + }; + + handleUsernameChange = (value: string) => { + this.setState({ username: value }); + }; + + handlePasswordChange = (value: string) => { + this.setState({ password: value }); + }; + + isValid() { + return this.state.username && this.state.password; + } + + areCredentialsInvalid() { + const { t, error } = this.props; + if (error instanceof UnauthorizedError) { + return new Error(t("errorNotification.wrongLoginCredentials")); + } else { + return error; + } + } + + render() { + const { loading, classes, t } = this.props; + return ( + <div className="column is-4 box has-text-centered has-background-white-ter"> + <h3 className="title">{t("login.title")}</h3> + <p className="subtitle">{t("login.subtitle")}</p> + <div className={classNames("box", classes.avatarSpacing)}> + <figure className={classes.avatar}> + <Image + className={classes.avatarImage} + src="/images/blib.jpg" + alt={t("login.logo-alt")} + /> + </figure> + <ErrorNotification error={this.areCredentialsInvalid()}/> + <form onSubmit={this.handleSubmit}> + <InputField + placeholder={t("login.username-placeholder")} + autofocus={true} + onChange={this.handleUsernameChange} + /> + <InputField + placeholder={t("login.password-placeholder")} + type="password" + onChange={this.handlePasswordChange} + /> + <SubmitButton + label={t("login.submit")} + fullWidth={true} + loading={loading} + /> + </form> + </div> + </div> + ); + } + +} + +export default injectSheet(styles)(translate("commons")(LoginForm)); + + diff --git a/scm-ui/src/components/LoginInfo.js b/scm-ui/src/components/LoginInfo.js new file mode 100644 index 0000000000..3bd80eaddc --- /dev/null +++ b/scm-ui/src/components/LoginInfo.js @@ -0,0 +1,54 @@ +//@flow +import React from "react"; +import InfoBox from "./InfoBox"; +import type { InfoItem } from "./InfoItem"; + +type Props = { +}; + +type State = { + plugin?: InfoItem, + feature?: InfoItem, + error?: Error +}; + +class LoginInfo extends React.Component<Props, State> { + + constructor(props: Props) { + super(props); + this.state = { + }; + } + + componentDidMount() { + fetch("https://login-info.scm-manager.org/api/v1/login-info") + .then(response => response.json()) + .then(info => { + this.setState({ + plugin: info.plugin, + feature: info.feature, + error: undefined + }); + }) + .catch(error => { + this.setState({ + error + }); + }); + } + + render() { + const { plugin, feature, error } = this.state; + return ( + <div className="column is-7 is-offset-1 is-paddingless"> + <InfoBox item={feature} type="feature" error={error} /> + <InfoBox item={plugin} type="plugin" error={error} /> + </div> + ); + } + +} + +export default LoginInfo; + + diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index d14d9f5896..5d63698168 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -1,8 +1,6 @@ //@flow import React from "react"; import { Redirect, withRouter } from "react-router-dom"; -import injectSheet from "react-jss"; -import { translate } from "react-i18next"; import { login, isAuthenticated, @@ -10,148 +8,65 @@ import { getLoginFailure } from "../modules/auth"; import { connect } from "react-redux"; - -import { - InputField, - SubmitButton, - ErrorNotification, - Image, UnauthorizedError -} from "@scm-manager/ui-components"; -import classNames from "classnames"; import { getLoginLink } from "../modules/indexResource"; +import LoginForm from "../components/LoginForm"; +import LoginInfo from "../components/LoginInfo"; +import classNames from "classnames"; +import injectSheet from "react-jss"; const styles = { - avatar: { - marginTop: "-70px", - paddingBottom: "20px" - }, - avatarImage: { - border: "1px solid lightgray", - padding: "5px", - background: "#fff", - borderRadius: "50%", - width: "128px", - height: "128px" - }, - avatarSpacing: { - marginTop: "5rem" + section: { + paddingTop: "2em" } }; type Props = { authenticated: boolean, loading: boolean, - error: Error, + error?: Error, link: string, // dispatcher props login: (link: string, username: string, password: string) => void, // context props - t: string => string, classes: any, + t: string => string, from: any, location: any }; -type State = { - username: string, - password: string -}; +class Login extends React.Component<Props> { -class Login extends React.Component<Props, State> { - constructor(props: Props) { - super(props); - this.state = { username: "", password: "" }; - } - - handleUsernameChange = (value: string) => { - this.setState({ username: value }); + login = (username: string, password: string) => { + const { link, login } = this.props; + login(link, username, password); }; - handlePasswordChange = (value: string) => { - this.setState({ password: value }); - }; - - handleSubmit = (event: Event) => { - event.preventDefault(); - if (this.isValid()) { - this.props.login( - this.props.link, - this.state.username, - this.state.password - ); - } - }; - - isValid() { - return this.state.username && this.state.password; - } - - isInValid() { - return !this.isValid(); - } - - areCredentialsInvalid() { - const { t, error } = this.props; - if (error instanceof UnauthorizedError) { - return new Error(t("errorNotification.wrongLoginCredentials")); - } else { - return error; - } - } - renderRedirect = () => { const { from } = this.props.location.state || { from: { pathname: "/" } }; - return <Redirect to={from} />; + return <Redirect to={from}/>; }; render() { - const { authenticated, loading, t, classes } = this.props; + const { authenticated, loading, error, classes } = this.props; if (authenticated) { return this.renderRedirect(); } return ( - <section className="hero"> + <section className={classNames("hero", classes.section )}> <div className="hero-body"> - <div className="container has-text-centered"> - <div className="column is-4 is-offset-4"> - <h3 className="title">{t("login.title")}</h3> - <p className="subtitle">{t("login.subtitle")}</p> - <div className={classNames("box", classes.avatarSpacing)}> - <figure className={classes.avatar}> - <Image - className={classes.avatarImage} - src="/images/blib.jpg" - alt={t("login.logo-alt")} - /> - </figure> - <ErrorNotification error={this.areCredentialsInvalid()} /> - <form onSubmit={this.handleSubmit}> - <InputField - placeholder={t("login.username-placeholder")} - autofocus={true} - onChange={this.handleUsernameChange} - /> - <InputField - placeholder={t("login.password-placeholder")} - type="password" - onChange={this.handlePasswordChange} - /> - <SubmitButton - label={t("login.submit")} - fullWidth={true} - loading={loading} - /> - </form> - </div> + <div className="container"> + <div className="columns"> + <LoginForm loading={loading} error={error} login={this.login}/> + <LoginInfo/> </div> </div> </div> </section> - ); + ); } } @@ -179,6 +94,6 @@ const StyledLogin = injectSheet(styles)( connect( mapStateToProps, mapDispatchToProps - )(translate("commons")(Login)) + )(Login) ); export default withRouter(StyledLogin); From 3823c033b9a4fea0fb7d0abf5c6449cc83532667 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 13 Aug 2019 09:45:37 +0200 Subject: [PATCH 069/135] added configuration option for login info url --- .../sonia/scm/config/ScmConfiguration.java | 16 ++++++++ .../packages/ui-types/src/Config.js | 1 + scm-ui/public/locales/de/config.json | 6 ++- scm-ui/public/locales/en/config.json | 6 ++- .../src/admin/components/form/ConfigForm.js | 2 + .../admin/components/form/GeneralSettings.js | 15 ++++++++ scm-ui/src/components/LoginInfo.js | 4 +- scm-ui/src/containers/Login.js | 18 ++++++--- scm-ui/src/modules/indexResource.js | 4 ++ .../sonia/scm/api/v2/resources/ConfigDto.java | 1 + .../api/v2/resources/IndexDtoGenerator.java | 11 +++++- ...ConfigDtoToScmConfigurationMapperTest.java | 2 + .../api/v2/resources/IndexResourceTest.java | 37 +++++++++++++++++-- ...ScmConfigurationToConfigDtoMapperTest.java | 2 + 14 files changed, 111 insertions(+), 14 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 8d3db8b348..d23bbaf07d 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -75,6 +75,11 @@ public class ScmConfiguration implements Configuration { public static final String DEFAULT_PLUGINURL = "http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false"; + /** + * Default url for login information (plugin and feature tips on the login page). + */ + public static final String DEFAULT_LOGIN_INFO_URL = "https://login-info.scm-manager.org/api/v1/login-info"; + /** * Default plugin url from version 1.0 */ @@ -177,6 +182,9 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "namespace-strategy") private String namespaceStrategy = "UsernameNamespaceStrategy"; + @XmlElement(name = "login-info-url") + private String loginInfoUrl = DEFAULT_LOGIN_INFO_URL; + /** * Calls the {@link sonia.scm.ConfigChangedListener#configChanged(Object)} @@ -216,6 +224,7 @@ public class ScmConfiguration implements Configuration { this.loginAttemptLimitTimeout = other.loginAttemptLimitTimeout; this.enabledXsrfProtection = other.enabledXsrfProtection; this.namespaceStrategy = other.namespaceStrategy; + this.loginInfoUrl = other.loginInfoUrl; } /** @@ -350,6 +359,9 @@ public class ScmConfiguration implements Configuration { return namespaceStrategy; } + public String getLoginInfoUrl() { + return loginInfoUrl; + } /** * Returns true if failed authenticators are skipped. @@ -477,6 +489,10 @@ public class ScmConfiguration implements Configuration { this.namespaceStrategy = namespaceStrategy; } + public void setLoginInfoUrl(String loginInfoUrl) { + this.loginInfoUrl = loginInfoUrl; + } + @Override // Only for permission checks, don't serialize to XML @XmlTransient diff --git a/scm-ui-components/packages/ui-types/src/Config.js b/scm-ui-components/packages/ui-types/src/Config.js index 0e82076848..5a9522585f 100644 --- a/scm-ui-components/packages/ui-types/src/Config.js +++ b/scm-ui-components/packages/ui-types/src/Config.js @@ -21,5 +21,6 @@ export type Config = { loginAttemptLimitTimeout: number, enabledXsrfProtection: boolean, namespaceStrategy: string, + loginInfoUrl: string, _links: Links }; diff --git a/scm-ui/public/locales/de/config.json b/scm-ui/public/locales/de/config.json index 20977583ac..f00b3e2357 100644 --- a/scm-ui/public/locales/de/config.json +++ b/scm-ui/public/locales/de/config.json @@ -43,7 +43,8 @@ "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", "plugin-url": "Plugin URL", "enabled-xsrf-protection": "XSRF Protection aktivieren", - "namespace-strategy": "Namespace Strategie" + "namespace-strategy": "Namespace Strategie", + "login-info-url": "Login Info URL", }, "validation": { "date-format-invalid": "Das Datumsformat ist ungültig", @@ -73,6 +74,7 @@ "proxyUserHelpText": "Der Benutzername für die Proxy Server Anmeldung.", "proxyExcludesHelpText": "Glob patterns für Hostnamen, die von den Proxy-Einstellungen ausgeschlossen werden sollen.", "enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.", - "nameSpaceStrategyHelpText": "Strategie für Namespaces." + "nameSpaceStrategyHelpText": "Strategie für Namespaces.", + "loginInfoUrlHelpText": "URL zu den Login Informationen (Plugin und Feature Tipps auf der Login Seite)." } } diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index 1aa13a3150..62215df827 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -43,7 +43,8 @@ "skip-failed-authenticators": "Skip Failed Authenticators", "plugin-url": "Plugin URL", "enabled-xsrf-protection": "Enabled XSRF Protection", - "namespace-strategy": "Namespace Strategy" + "namespace-strategy": "Namespace Strategy", + "login-info-url": "Login Info URL" }, "validation": { "date-format-invalid": "The date format is not valid", @@ -73,6 +74,7 @@ "proxyUserHelpText": "The username for the proxy server authentication.", "proxyExcludesHelpText": "Glob patterns for hostnames, which should be excluded from proxy settings.", "enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.", - "nameSpaceStrategyHelpText": "The namespace strategy." + "nameSpaceStrategyHelpText": "The namespace strategy.", + "loginInfoUrlHelpText": "URL to login information (plugin and feature tips at login page)." } } diff --git a/scm-ui/src/admin/components/form/ConfigForm.js b/scm-ui/src/admin/components/form/ConfigForm.js index 20e2db2599..25a24c4d28 100644 --- a/scm-ui/src/admin/components/form/ConfigForm.js +++ b/scm-ui/src/admin/components/form/ConfigForm.js @@ -54,6 +54,7 @@ class ConfigForm extends React.Component<Props, State> { loginAttemptLimitTimeout: 0, enabledXsrfProtection: true, namespaceStrategy: "", + loginInfoUrl: "", _links: {} }, showNotification: false, @@ -119,6 +120,7 @@ class ConfigForm extends React.Component<Props, State> { {noPermissionNotification} <GeneralSettings namespaceStrategies={namespaceStrategies} + loginInfoUrl={config.loginInfoUrl} realmDescription={config.realmDescription} enableRepositoryArchive={config.enableRepositoryArchive} disableGroupingGrid={config.disableGroupingGrid} diff --git a/scm-ui/src/admin/components/form/GeneralSettings.js b/scm-ui/src/admin/components/form/GeneralSettings.js index 67de20ddc1..a2fb1127af 100644 --- a/scm-ui/src/admin/components/form/GeneralSettings.js +++ b/scm-ui/src/admin/components/form/GeneralSettings.js @@ -7,6 +7,7 @@ import NamespaceStrategySelect from "./NamespaceStrategySelect"; type Props = { realmDescription: string, + loginInfoUrl: string, enableRepositoryArchive: boolean, disableGroupingGrid: boolean, dateFormat: string, @@ -27,6 +28,7 @@ class GeneralSettings extends React.Component<Props> { const { t, realmDescription, + loginInfoUrl, enabledXsrfProtection, namespaceStrategy, hasUpdatePermission, @@ -57,6 +59,15 @@ class GeneralSettings extends React.Component<Props> { </div> </div> <div className="columns"> + <div className="column is-half"> + <InputField + label={t("general-settings.login-info-url")} + onChange={this.handleLoginInfoUrlChange} + value={loginInfoUrl} + disabled={!hasUpdatePermission} + helpText={t("help.loginInfoUrlHelpText")} + /> + </div> <div className="column is-half"> <Checkbox checked={enabledXsrfProtection} @@ -71,6 +82,10 @@ class GeneralSettings extends React.Component<Props> { ); } + handleLoginInfoUrlChange = (value: string) => { + this.props.onChange(true, value, "loginInfoUrl"); + }; + handleRealmDescriptionChange = (value: string) => { this.props.onChange(true, value, "realmDescription"); }; diff --git a/scm-ui/src/components/LoginInfo.js b/scm-ui/src/components/LoginInfo.js index 3bd80eaddc..63ce6f3dbe 100644 --- a/scm-ui/src/components/LoginInfo.js +++ b/scm-ui/src/components/LoginInfo.js @@ -4,6 +4,7 @@ import InfoBox from "./InfoBox"; import type { InfoItem } from "./InfoItem"; type Props = { + loginInfoLink: string }; type State = { @@ -21,7 +22,8 @@ class LoginInfo extends React.Component<Props, State> { } componentDidMount() { - fetch("https://login-info.scm-manager.org/api/v1/login-info") + const { loginInfoLink } = this.props; + fetch(loginInfoLink) .then(response => response.json()) .then(info => { this.setState({ diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 5d63698168..4b30f9cb0f 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -8,7 +8,7 @@ import { getLoginFailure } from "../modules/auth"; import { connect } from "react-redux"; -import { getLoginLink } from "../modules/indexResource"; +import { getLoginLink, getLoginInfoLink } from "../modules/indexResource"; import LoginForm from "../components/LoginForm"; import LoginInfo from "../components/LoginInfo"; import classNames from "classnames"; @@ -25,6 +25,7 @@ type Props = { loading: boolean, error?: Error, link: string, + loginInfoLink?: string, // dispatcher props login: (link: string, username: string, password: string) => void, @@ -49,19 +50,24 @@ class Login extends React.Component<Props> { }; render() { - const { authenticated, loading, error, classes } = this.props; + const { authenticated, loginInfoLink, loading, error, classes } = this.props; if (authenticated) { return this.renderRedirect(); } + let loginInfo; + if (loginInfoLink) { + loginInfo = <LoginInfo loginInfoLink={loginInfoLink}/> + } + return ( <section className={classNames("hero", classes.section )}> <div className="hero-body"> <div className="container"> - <div className="columns"> + <div className="columns is-centered"> <LoginForm loading={loading} error={error} login={this.login}/> - <LoginInfo/> + {loginInfo} </div> </div> </div> @@ -75,11 +81,13 @@ const mapStateToProps = state => { const loading = isLoginPending(state); const error = getLoginFailure(state); const link = getLoginLink(state); + const loginInfoLink = getLoginInfoLink(state); return { authenticated, loading, error, - link + link, + loginInfoLink }; }; diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js index 9bfa620674..9676faffba 100644 --- a/scm-ui/src/modules/indexResource.js +++ b/scm-ui/src/modules/indexResource.js @@ -168,6 +168,10 @@ export function getSvnConfigLink(state: Object) { return getLink(state, "svnConfig"); } +export function getLoginInfoLink(state: Object) { + return getLink(state, "loginInfo"); +} + export function getUserAutoCompleteLink(state: Object): string { const link = getLinkCollection(state, "autocomplete").find( i => i.name === "users" diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index 1852d6fdc4..30d936d4c5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -32,6 +32,7 @@ public class ConfigDto extends HalRepresentation { private long loginAttemptLimitTimeout; private boolean enabledXsrfProtection; private String namespaceStrategy; + private String loginInfoUrl; @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index c7b52861dc..cace57577c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import com.google.common.base.Strings; import com.google.common.collect.Lists; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Link; @@ -7,6 +8,7 @@ import de.otto.edison.hal.Links; import org.apache.shiro.SecurityUtils; import sonia.scm.SCMContextProvider; import sonia.scm.config.ConfigurationPermissions; +import sonia.scm.config.ScmConfiguration; import sonia.scm.group.GroupPermissions; import sonia.scm.plugin.PluginPermissions; import sonia.scm.repository.RepositoryRolePermissions; @@ -23,11 +25,13 @@ public class IndexDtoGenerator extends HalAppenderMapper { private final ResourceLinks resourceLinks; private final SCMContextProvider scmContextProvider; + private final ScmConfiguration configuration; @Inject - public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider) { + public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider, ScmConfiguration configuration) { this.resourceLinks = resourceLinks; this.scmContextProvider = scmContextProvider; + this.configuration = configuration; } public IndexDto generate() { @@ -36,6 +40,11 @@ public class IndexDtoGenerator extends HalAppenderMapper { builder.self(resourceLinks.index().self()); builder.single(link("uiPlugins", resourceLinks.uiPluginCollection().self())); + String loginInfoUrl = configuration.getLoginInfoUrl(); + if (!Strings.isNullOrEmpty(loginInfoUrl)) { + builder.single(link("loginInfo", loginInfoUrl)); + } + if (SecurityUtils.getSubject().isAuthenticated()) { builder.single( link("me", resourceLinks.me().self()), diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java index dd09e50266..e7ae446185 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java @@ -50,6 +50,7 @@ public class ConfigDtoToScmConfigurationMapperTest { assertEquals(40 , config.getLoginAttemptLimitTimeout()); assertTrue(config.isEnabledXsrfProtection()); assertEquals("username", config.getNamespaceStrategy()); + assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl()); } private ConfigDto createDefaultDto() { @@ -73,6 +74,7 @@ public class ConfigDtoToScmConfigurationMapperTest { configDto.setLoginAttemptLimitTimeout(40); configDto.setEnabledXsrfProtection(true); configDto.setNamespaceStrategy("username"); + configDto.setLoginInfoUrl("https://scm-manager.org/login-info"); return configDto; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java index 93099cf5ea..9dfa5fca28 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java @@ -3,9 +3,11 @@ package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import org.assertj.core.api.Assertions; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import sonia.scm.SCMContextProvider; +import sonia.scm.config.ScmConfiguration; import java.net.URI; import java.util.Optional; @@ -19,9 +21,22 @@ public class IndexResourceTest { @Rule public final ShiroRule shiroRule = new ShiroRule(); - private final SCMContextProvider scmContextProvider = mock(SCMContextProvider.class); - private final IndexDtoGenerator indexDtoGenerator = new IndexDtoGenerator(ResourceLinksMock.createMock(URI.create("/")), scmContextProvider); - private final IndexResource indexResource = new IndexResource(indexDtoGenerator); + private ScmConfiguration configuration; + private SCMContextProvider scmContextProvider; + private IndexResource indexResource; + + + @Before + public void setUpObjectUnderTest() { + this.configuration = new ScmConfiguration(); + this.scmContextProvider = mock(SCMContextProvider.class); + IndexDtoGenerator generator = new IndexDtoGenerator( + ResourceLinksMock.createMock(URI.create("/")), + scmContextProvider, + configuration + ); + this.indexResource = new IndexResource(generator); + } @Test public void shouldRenderLoginUrlsForUnauthenticatedRequest() { @@ -30,6 +45,22 @@ public class IndexResourceTest { Assertions.assertThat(index.getLinks().getLinkBy("login")).matches(Optional::isPresent); } + @Test + public void shouldRenderLoginInfoUrl() { + IndexDto index = indexResource.getIndex(); + + Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isPresent(); + } + + @Test + public void shouldNotRenderLoginInfoUrlWhenNoUrlIsConfigured() { + configuration.setLoginInfoUrl(""); + + IndexDto index = indexResource.getIndex(); + + Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isNotPresent(); + } + @Test public void shouldRenderSelfLinkForUnauthenticatedRequest() { IndexDto index = indexResource.getIndex(); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java index ee940a9721..6ae6d5d2f1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java @@ -80,6 +80,7 @@ public class ScmConfigurationToConfigDtoMapperTest { assertEquals(2 , dto.getLoginAttemptLimitTimeout()); assertTrue(dto.isEnabledXsrfProtection()); assertEquals("username", dto.getNamespaceStrategy()); + assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref()); @@ -118,6 +119,7 @@ public class ScmConfigurationToConfigDtoMapperTest { config.setLoginAttemptLimitTimeout(2); config.setEnabledXsrfProtection(true); config.setNamespaceStrategy("username"); + config.setLoginInfoUrl("https://scm-manager.org/login-info"); return config; } From bbfd5195e169179d43523edec2ad13b10c47c1b6 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 14 Aug 2019 09:12:08 +0200 Subject: [PATCH 070/135] add avatarUrl and DisplayName to dtd validation --- docs/dtd/plugin/2.0.0-01.dtd | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/dtd/plugin/2.0.0-01.dtd b/docs/dtd/plugin/2.0.0-01.dtd index a4f5a2d831..4b330fd652 100644 --- a/docs/dtd/plugin/2.0.0-01.dtd +++ b/docs/dtd/plugin/2.0.0-01.dtd @@ -29,7 +29,7 @@ <!ELEMENT scm-version (#PCDATA)> <!--- contains informations of the plugin for the plugin backend --> -<!ELEMENT information (author|category|description|name|version)*> +<!ELEMENT information (author|category|description|name|version|displayName|avatarUrl)*> <!--- plugin author --> <!ELEMENT author (#PCDATA)> @@ -46,6 +46,12 @@ <!--- the current version of the plugin --> <!ELEMENT version (#PCDATA)> + <!--- plugin displayName --> + <!ELEMENT displayName (#PCDATA)> + + <!--- url of the plugin avatar --> + <!ELEMENT avatarUrl (#PCDATA)> + <!--- true if the plugin should load child classes first, the default is false --> <!ELEMENT child-first-classloader (#PCDATA)> From 339caaf6909bfbcc7b03378492fc589ec4e6cfbf Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 14 Aug 2019 09:12:38 +0200 Subject: [PATCH 071/135] keep old parameters --- scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 6868182f10..5c504291d2 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -74,6 +74,8 @@ public class ScmConfiguration implements Configuration { */ public static final String DEFAULT_PLUGINURL = "http://download.scm-manager.org/api/v2/plugins.json"; + // Keep the parameters + // "http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false"; /** * Default plugin url from version 1.0 From cce45083b67d2d460bf5239bc344230827bfbd14 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 14 Aug 2019 09:13:29 +0200 Subject: [PATCH 072/135] use lombok for equalsAndHashcode + toString / add displayName --- .../sonia/scm/plugin/PluginInformation.java | 55 ++----------------- .../packages/ui-types/src/Plugin.js | 1 + 2 files changed, 7 insertions(+), 49 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 99ad1e82e8..bb33069afe 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -35,10 +35,10 @@ package sonia.scm.plugin; import com.github.sdorra.ssp.PermissionObject; import com.github.sdorra.ssp.StaticPermissions; -import com.google.common.base.MoreObjects; -import com.google.common.base.Objects; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; +import lombok.ToString; import sonia.scm.Validateable; import sonia.scm.util.Util; @@ -63,6 +63,8 @@ import java.io.Serializable; @XmlRootElement(name = "plugin-information") @Getter @Setter +@EqualsAndHashCode +@ToString public class PluginInformation implements PermissionObject, Validateable, Cloneable, Serializable { private static final long serialVersionUID = 461382048865977206L; @@ -74,6 +76,7 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea private String name; private PluginState state; private String version; + private String displayName; private String avatarUrl; @Override @@ -85,6 +88,7 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea clone.setDescription(description); clone.setState(state); clone.setVersion(version); + clone.setDisplayName(displayName); clone.setAvatarUrl(avatarUrl); if (condition != null) { @@ -94,53 +98,6 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea return clone; } - @Override - public boolean equals(Object obj) { - if (obj == null) { - return false; - } - - if (getClass() != obj.getClass()) { - return false; - } - - final PluginInformation other = (PluginInformation) obj; - - //J- - return - Objects.equal(author, other.author) - && Objects.equal(category, other.category) - && Objects.equal(condition, other.condition) - && Objects.equal(description, other.description) - && Objects.equal(name, other.name) - && Objects.equal(state, other.state) - && Objects.equal(version, other.version) - && Objects.equal(avatarUrl, other.avatarUrl); - //J+ - } - - @Override - public int hashCode() { - return Objects.hashCode(author, category, condition, - description, name, state, version, avatarUrl); - } - - @Override - public String toString() { - //J- - return MoreObjects.toStringHelper(this) - .add("author", author) - .add("category", category) - .add("condition", condition) - .add("description", description) - .add("name", name) - .add("state", state) - .add("version", version) - .add("avatarUrl", avatarUrl) - .toString(); - //J+ - } - @Override public String getId() { return getName(true); diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 72e4908a54..5a4b015224 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -6,6 +6,7 @@ export type Plugin = { type: string, version: string, author: string, + displayName: string, avatarUrl: string, description?: string, _links: Links From 372e629dfce8f80936a8135da07d01818696bf8c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 10:08:48 +0200 Subject: [PATCH 073/135] do not show login tips, if they could not fetched --- scm-ui/src/components/InfoBox.js | 28 ++++----------- scm-ui/src/components/LoginForm.js | 4 +-- scm-ui/src/components/LoginInfo.js | 56 ++++++++++++++++++++++-------- scm-ui/src/containers/Login.js | 13 ++----- 4 files changed, 53 insertions(+), 48 deletions(-) diff --git a/scm-ui/src/components/InfoBox.js b/scm-ui/src/components/InfoBox.js index 95d487a210..794ad24684 100644 --- a/scm-ui/src/components/InfoBox.js +++ b/scm-ui/src/components/InfoBox.js @@ -33,15 +33,14 @@ const styles = { }, "@media (max-width: 768px)": { link: { - width: "100%", + width: "100%" } } }; type Props = { type: "plugin" | "feature", - item?: InfoItem, - error?: Error, + item: InfoItem, // context props classes: any, @@ -51,27 +50,16 @@ type Props = { class InfoBox extends React.Component<Props> { renderBody = () => { - const { item, error, t } = this.props; + const { item, t } = this.props; const bodyClasses = classNames("media-content", "content", this.props.classes.content); - - if (error) { - return ( - <div className={bodyClasses}> - <h4>{t("login.error")}</h4> - <ErrorNotification error={error}/> - </div> - ); - } - const title = item ? item.title : t("login.loading"); const summary = item ? item.summary : t("login.loading"); - return ( <div className={bodyClasses}> <h4> - <a href={this.createHref()}>{title}</a> + <a href={item._links.self.href}>{title}</a> </h4> <p>{summary}</p> </div> @@ -79,15 +67,11 @@ class InfoBox extends React.Component<Props> { }; - createHref = () => { - const { item } = this.props; - return item ? item._links.self.href : "#"; - }; createLink = () => { - const { classes } = this.props; + const { item, classes } = this.props; // eslint-disable-next-line jsx-a11y/anchor-has-content - return <a href={this.createHref()} className={classes.link} />; + return <a href={item._links.self.href} className={classes.link}/>; }; render() { diff --git a/scm-ui/src/components/LoginForm.js b/scm-ui/src/components/LoginForm.js index c1ad8c0ea5..95d4c0b7d3 100644 --- a/scm-ui/src/components/LoginForm.js +++ b/scm-ui/src/components/LoginForm.js @@ -26,7 +26,7 @@ const styles = { type Props = { error?: Error, loading: boolean, - login: (username: string, password: string) => void, + loginHandler: (username: string, password: string) => void, // context props t: string => string, @@ -48,7 +48,7 @@ class LoginForm extends React.Component<Props, State> { handleSubmit = (event: Event) => { event.preventDefault(); if (this.isValid()) { - this.props.login( + this.props.loginHandler( this.state.username, this.state.password ); diff --git a/scm-ui/src/components/LoginInfo.js b/scm-ui/src/components/LoginInfo.js index 63ce6f3dbe..a7e488e646 100644 --- a/scm-ui/src/components/LoginInfo.js +++ b/scm-ui/src/components/LoginInfo.js @@ -2,15 +2,24 @@ import React from "react"; import InfoBox from "./InfoBox"; import type { InfoItem } from "./InfoItem"; +import LoginForm from "./LoginForm"; +import { Loading } from "@scm-manager/ui-components"; type Props = { - loginInfoLink: string + loginInfoLink?: string, + loading?: boolean, + error?: Error, + loginHandler: (username: string, password: string) => void, +}; + +type LoginInfoResponse = { + plugin?: InfoItem, + feature?: InfoItem }; type State = { - plugin?: InfoItem, - feature?: InfoItem, - error?: Error + info?: LoginInfoResponse, + loading?: boolean, }; class LoginInfo extends React.Component<Props, State> { @@ -18,34 +27,53 @@ class LoginInfo extends React.Component<Props, State> { constructor(props: Props) { super(props); this.state = { + loading: !!props.loginInfoLink }; } componentDidMount() { const { loginInfoLink } = this.props; + if (!loginInfoLink) { + return; + } fetch(loginInfoLink) .then(response => response.json()) .then(info => { this.setState({ - plugin: info.plugin, - feature: info.feature, - error: undefined + info, + loading: false }); }) - .catch(error => { + .catch(() => { this.setState({ - error + loading: false }); }); } + createInfoPanel = (info: LoginInfoResponse) => ( + <div className="column is-7 is-offset-1 is-paddingless"> + <InfoBox item={info.feature} type="feature" /> + <InfoBox item={info.plugin} type="plugin" /> + </div> + ); + render() { - const { plugin, feature, error } = this.state; + const { info, loading } = this.state; + if (loading) { + return <Loading/>; + } + + let infoPanel; + if (info) { + infoPanel = this.createInfoPanel(info); + } + return ( - <div className="column is-7 is-offset-1 is-paddingless"> - <InfoBox item={feature} type="feature" error={error} /> - <InfoBox item={plugin} type="plugin" error={error} /> - </div> + <> + <LoginForm {...this.props} /> + {infoPanel} + </> ); } diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 4b30f9cb0f..f8246ab88b 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -9,7 +9,6 @@ import { } from "../modules/auth"; import { connect } from "react-redux"; import { getLoginLink, getLoginInfoLink } from "../modules/indexResource"; -import LoginForm from "../components/LoginForm"; import LoginInfo from "../components/LoginInfo"; import classNames from "classnames"; import injectSheet from "react-jss"; @@ -39,7 +38,7 @@ type Props = { class Login extends React.Component<Props> { - login = (username: string, password: string) => { + handleLogin = (username: string, password: string): void => { const { link, login } = this.props; login(link, username, password); }; @@ -50,24 +49,18 @@ class Login extends React.Component<Props> { }; render() { - const { authenticated, loginInfoLink, loading, error, classes } = this.props; + const { authenticated, classes, ...restProps } = this.props; if (authenticated) { return this.renderRedirect(); } - let loginInfo; - if (loginInfoLink) { - loginInfo = <LoginInfo loginInfoLink={loginInfoLink}/> - } - return ( <section className={classNames("hero", classes.section )}> <div className="hero-body"> <div className="container"> <div className="columns is-centered"> - <LoginForm loading={loading} error={error} login={this.login}/> - {loginInfo} + <LoginInfo loginHandler={this.handleLogin} {...restProps} /> </div> </div> </div> From 0859353f46408b7f0f5272d7cbad4c2c28006db8 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 10:20:59 +0200 Subject: [PATCH 074/135] use a timeout of 1s for fetching login info --- scm-ui/src/components/LoginInfo.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/scm-ui/src/components/LoginInfo.js b/scm-ui/src/components/LoginInfo.js index a7e488e646..3f7ea5ede3 100644 --- a/scm-ui/src/components/LoginInfo.js +++ b/scm-ui/src/components/LoginInfo.js @@ -31,19 +31,32 @@ class LoginInfo extends React.Component<Props, State> { }; } - componentDidMount() { - const { loginInfoLink } = this.props; - if (!loginInfoLink) { - return; - } - fetch(loginInfoLink) + fetchLoginInfo = (url: string) => { + return fetch(url) .then(response => response.json()) .then(info => { this.setState({ info, loading: false }); - }) + }); + }; + + timeout = (ms: number, promise: Promise<any>) => { + return new Promise<LoginInfoResponse>((resolve, reject) => { + setTimeout(() => { + reject(new Error("timeout during fetch of login info")); + }, ms); + promise.then(resolve, reject); + }); + }; + + componentDidMount() { + const { loginInfoLink } = this.props; + if (!loginInfoLink) { + return; + } + this.timeout(1000, this.fetchLoginInfo(loginInfoLink)) .catch(() => { this.setState({ loading: false From e870d90b728b54b8ba2b5cb741536cfd288917f4 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 10:30:48 +0200 Subject: [PATCH 075/135] simplify info link handling --- scm-ui/src/components/InfoBox.js | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/scm-ui/src/components/InfoBox.js b/scm-ui/src/components/InfoBox.js index 794ad24684..3f1a4fe32a 100644 --- a/scm-ui/src/components/InfoBox.js +++ b/scm-ui/src/components/InfoBox.js @@ -25,16 +25,8 @@ const styles = { marginLeft: "1.5em" }, link: { - width: "60%", - height: "200px", - position: "absolute", - cursor: "pointer", - zIndex: 20 - }, - "@media (max-width: 768px)": { - link: { - width: "100%" - } + display: "block", + marginBottom: "1.5rem" } }; @@ -58,28 +50,18 @@ class InfoBox extends React.Component<Props> { return ( <div className={bodyClasses}> - <h4> - <a href={item._links.self.href}>{title}</a> - </h4> + <h4 className="has-text-link">{title}</h4> <p>{summary}</p> </div> ); }; - - createLink = () => { - const { item, classes } = this.props; - // eslint-disable-next-line jsx-a11y/anchor-has-content - return <a href={item._links.self.href} className={classes.link}/>; - }; - render() { - const { type, classes, t } = this.props; + const { item, type, classes, t } = this.props; const icon = type === "plugin" ? "puzzle-piece" : "star"; return ( - <> - {this.createLink()} + <a href={item._links.self.href} className={classes.link}> <div className="box media"> <figure className="media-left"> <div @@ -91,7 +73,7 @@ class InfoBox extends React.Component<Props> { </figure> {this.renderBody()} </div> - </> + </a> ); } From 71adb69fe49d7e98a6f5dab7841e4ea610e7e961 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 10:31:01 +0200 Subject: [PATCH 076/135] adjust help texts for login info --- scm-ui/public/locales/de/config.json | 4 ++-- scm-ui/public/locales/en/config.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scm-ui/public/locales/de/config.json b/scm-ui/public/locales/de/config.json index f00b3e2357..a94965fa68 100644 --- a/scm-ui/public/locales/de/config.json +++ b/scm-ui/public/locales/de/config.json @@ -44,7 +44,7 @@ "plugin-url": "Plugin URL", "enabled-xsrf-protection": "XSRF Protection aktivieren", "namespace-strategy": "Namespace Strategie", - "login-info-url": "Login Info URL", + "login-info-url": "Login Info URL" }, "validation": { "date-format-invalid": "Das Datumsformat ist ungültig", @@ -75,6 +75,6 @@ "proxyExcludesHelpText": "Glob patterns für Hostnamen, die von den Proxy-Einstellungen ausgeschlossen werden sollen.", "enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.", "nameSpaceStrategyHelpText": "Strategie für Namespaces.", - "loginInfoUrlHelpText": "URL zu den Login Informationen (Plugin und Feature Tipps auf der Login Seite)." + "loginInfoUrlHelpText": "URL zu der Login Information (Plugin und Feature Tipps auf der Login Seite). Um die Login Information zu deaktivieren, kann das Feld leer gelassen werden." } } diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index 62215df827..894d0563ba 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -75,6 +75,6 @@ "proxyExcludesHelpText": "Glob patterns for hostnames, which should be excluded from proxy settings.", "enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.", "nameSpaceStrategyHelpText": "The namespace strategy.", - "loginInfoUrlHelpText": "URL to login information (plugin and feature tips at login page)." + "loginInfoUrlHelpText": "URL to login information (plugin and feature tips at login page). If this is omitted, no login information will be displayed." } } From a698444afe1cfc09763aac840a0d9547629594cd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 10:40:38 +0200 Subject: [PATCH 077/135] remove unused import --- scm-ui/src/components/InfoBox.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scm-ui/src/components/InfoBox.js b/scm-ui/src/components/InfoBox.js index 3f1a4fe32a..f6fc170826 100644 --- a/scm-ui/src/components/InfoBox.js +++ b/scm-ui/src/components/InfoBox.js @@ -1,6 +1,5 @@ //@flow import * as React from "react"; -import { ErrorNotification } from "@scm-manager/ui-components"; import injectSheet from "react-jss"; import classNames from "classnames"; import { translate } from "react-i18next"; From 924efc6187565bc1c7539e1c116d4fa8d8cc8775 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Thu, 15 Aug 2019 12:50:56 +0200 Subject: [PATCH 078/135] fix merge error --- .../test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java | 1 - 1 file changed, 1 deletion(-) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index c4c885fa71..1aef4e57cb 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -50,7 +50,6 @@ public class ResourceLinksMock { when(resourceLinks.repositoryRole()).thenReturn(new ResourceLinks.RepositoryRoleLinks(uriInfo)); when(resourceLinks.repositoryRoleCollection()).thenReturn(new ResourceLinks.RepositoryRoleCollectionLinks(uriInfo)); when(resourceLinks.namespaceStrategies()).thenReturn(new ResourceLinks.NamespaceStrategiesLinks(uriInfo)); - when(resourceLinks.pluginCollection()).thenReturn(new ResourceLinks.PluginCollectionLinks(uriInfo)); return resourceLinks; } From d4d99cb7d58ba7f1c0cf01102626bc7e5e098e4c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 12:34:37 +0000 Subject: [PATCH 079/135] Close branch feature/login_info From 55e4568ee51383b38163926fe678f95e2b25241f Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 15 Aug 2019 17:01:15 +0200 Subject: [PATCH 080/135] use mapstruct for dto mapping and fix missing fields --- docs/dtd/plugin/2.0.0-01.dtd | 8 +- .../sonia/scm/config/ScmConfiguration.java | 4 +- .../sonia/scm/plugin/PluginInformation.java | 30 +++---- scm-plugins/scm-git-plugin/pom.xml | 2 +- .../main/resources/META-INF/scm/plugin.xml | 8 +- scm-plugins/scm-hg-plugin/pom.xml | 2 +- .../main/resources/META-INF/scm/plugin.xml | 9 +- scm-plugins/scm-legacy-plugin/pom.xml | 5 +- scm-plugins/scm-svn-plugin/pom.xml | 2 +- .../main/resources/META-INF/scm/plugin.xml | 8 +- .../packages/ui-types/src/Plugin.js | 7 +- .../scm/api/v2/resources/MapperModule.java | 2 + .../v2/resources/PluginCenterDtoMapper.java | 33 ------- .../sonia/scm/api/v2/resources/PluginDto.java | 7 +- .../scm/api/v2/resources/PluginDtoMapper.java | 35 ++++---- .../scm/plugin/DefaultPluginManager.java | 13 +-- .../resources => plugin}/PluginCenterDto.java | 5 +- .../scm/plugin/PluginCenterDtoMapper.java | 27 ++++++ .../api/v2/resources/PluginDtoMapperTest.java | 88 +++++++++++++++++++ .../PluginCenterDtoMapperTest.java | 22 ++--- 20 files changed, 181 insertions(+), 136 deletions(-) delete mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java rename scm-webapp/src/main/java/sonia/scm/{api/v2/resources => plugin}/PluginCenterDto.java (96%) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java rename scm-webapp/src/test/java/sonia/scm/{api/v2/resources => plugin}/PluginCenterDtoMapperTest.java (79%) diff --git a/docs/dtd/plugin/2.0.0-01.dtd b/docs/dtd/plugin/2.0.0-01.dtd index 4b330fd652..954a2c2219 100644 --- a/docs/dtd/plugin/2.0.0-01.dtd +++ b/docs/dtd/plugin/2.0.0-01.dtd @@ -46,11 +46,11 @@ <!--- the current version of the plugin --> <!ELEMENT version (#PCDATA)> - <!--- plugin displayName --> - <!ELEMENT displayName (#PCDATA)> +<!--- plugin displayName --> +<!ELEMENT displayName (#PCDATA)> - <!--- url of the plugin avatar --> - <!ELEMENT avatarUrl (#PCDATA)> +<!--- url of the plugin avatar --> +<!ELEMENT avatarUrl (#PCDATA)> <!--- true if the plugin should load child classes first, the default is false --> <!ELEMENT child-first-classloader (#PCDATA)> diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 5c504291d2..b50c64b321 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -73,9 +73,7 @@ public class ScmConfiguration implements Configuration { * Default plugin url */ public static final String DEFAULT_PLUGINURL = - "http://download.scm-manager.org/api/v2/plugins.json"; - // Keep the parameters - // "http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false"; + "http://download.scm-manager.org/api/v2/plugins.json?os={os}&arch={arch}&snapshot=false&version={version}"; /** * Default plugin url from version 1.0 diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index bb33069afe..22911041d4 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -35,10 +35,7 @@ package sonia.scm.plugin; import com.github.sdorra.ssp.PermissionObject; import com.github.sdorra.ssp.StaticPermissions; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; +import lombok.Data; import sonia.scm.Validateable; import sonia.scm.util.Util; @@ -52,6 +49,7 @@ import java.io.Serializable; /** * @author Sebastian Sdorra */ +@Data @StaticPermissions( value = "plugin", generatedClass = "PluginPermissions", @@ -61,40 +59,34 @@ import java.io.Serializable; ) @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "plugin-information") -@Getter -@Setter -@EqualsAndHashCode -@ToString public class PluginInformation implements PermissionObject, Validateable, Cloneable, Serializable { private static final long serialVersionUID = 461382048865977206L; - private String author; - private String category; - private PluginCondition condition; - private String description; private String name; - private PluginState state; private String version; private String displayName; + private String description; + private String author; + private String category; private String avatarUrl; + private PluginCondition condition; + private PluginState state; @Override public PluginInformation clone() { PluginInformation clone = new PluginInformation(); clone.setName(name); - clone.setAuthor(author); - clone.setCategory(category); - clone.setDescription(description); - clone.setState(state); clone.setVersion(version); clone.setDisplayName(displayName); + clone.setDescription(description); + clone.setAuthor(author); + clone.setCategory(category); clone.setAvatarUrl(avatarUrl); - + clone.setState(state); if (condition != null) { clone.setCondition(condition.clone()); } - return clone; } diff --git a/scm-plugins/scm-git-plugin/pom.xml b/scm-plugins/scm-git-plugin/pom.xml index a838e2f146..7f5531691f 100644 --- a/scm-plugins/scm-git-plugin/pom.xml +++ b/scm-plugins/scm-git-plugin/pom.xml @@ -10,7 +10,7 @@ </parent> <artifactId>scm-git-plugin</artifactId> - <name>scm-git-plugin</name> + <name>Git</name> <packaging>smp</packaging> <url>https://bitbucket.org/sdorra/scm-manager</url> <description>Plugin for the version control system Git</description> diff --git a/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml index ff699441a8..1706603e8a 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -47,13 +47,7 @@ <information> <author>Sebastian Sdorra</author> - <category>Git</category> - <tags> - <tag>git</tag> - <tag>scm</tag> - <tag>vcs</tag> - <tag>dvcs</tag> - </tags> + <category>Source Code Management</category> </information> <conditions> diff --git a/scm-plugins/scm-hg-plugin/pom.xml b/scm-plugins/scm-hg-plugin/pom.xml index 025f79add3..ace7642e91 100644 --- a/scm-plugins/scm-hg-plugin/pom.xml +++ b/scm-plugins/scm-hg-plugin/pom.xml @@ -10,7 +10,7 @@ </parent> <artifactId>scm-hg-plugin</artifactId> - <name>scm-hg-plugin</name> + <name>Mercurial</name> <packaging>smp</packaging> <url>https://bitbucket.org/sdorra/scm-manager</url> <description>Plugin for the version control system Mercurial</description> diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml index 1d0b05c4a8..90d4270a40 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -47,14 +47,7 @@ jo <information> <author>Sebastian Sdorra</author> - <category>Mercurial</category> - <tags> - <tag>mercurial</tag> - <tag>hg</tag> - <tag>scm</tag> - <tag>vcs</tag> - <tag>dvcs</tag> - </tags> + <category>Source Code Management</category> </information> <conditions> diff --git a/scm-plugins/scm-legacy-plugin/pom.xml b/scm-plugins/scm-legacy-plugin/pom.xml index 6cfa74ea61..5f876beaa2 100644 --- a/scm-plugins/scm-legacy-plugin/pom.xml +++ b/scm-plugins/scm-legacy-plugin/pom.xml @@ -6,8 +6,10 @@ <artifactId>scm-plugins</artifactId> <version>2.0.0-SNAPSHOT</version> </parent> - <groupId>sonia.scm.plugins</groupId> + <artifactId>scm-legacy-plugin</artifactId> + <name>Legacy</name> + <description>Support migrated repository urls and v1 passwords</description> <version>2.0.0-SNAPSHOT</version> <packaging>smp</packaging> @@ -21,6 +23,7 @@ <version>${servlet.version}</version> <scope>provided</scope> </dependency> + <dependency> <groupId>javax.ws.rs</groupId> <artifactId>jsr311-api</artifactId> diff --git a/scm-plugins/scm-svn-plugin/pom.xml b/scm-plugins/scm-svn-plugin/pom.xml index 4386efde5b..b924997711 100644 --- a/scm-plugins/scm-svn-plugin/pom.xml +++ b/scm-plugins/scm-svn-plugin/pom.xml @@ -10,7 +10,7 @@ </parent> <artifactId>scm-svn-plugin</artifactId> - <name>scm-svn-plugin</name> + <name>Subversion</name> <packaging>smp</packaging> <url>https://bitbucket.org/sdorra/scm-manager</url> <description>Plugin for the version control system Subversion</description> diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml index 302abd2b10..7a36cc1486 100644 --- a/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -47,13 +47,7 @@ <information> <author>Sebastian Sdorra</author> - <category>Subversion</category> - <tags> - <tag>subversion</tag> - <tag>scm</tag> - <tag>vcs</tag> - <tag>svn</tag> - </tags> + <category>Source Code Management</category> </information> <conditions> diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 5a4b015224..3f4f9858c1 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -1,14 +1,15 @@ //@flow import type {Collection, Links} from "./hal"; + export type Plugin = { name: string, - type: string, version: string, - author: string, displayName: string, - avatarUrl: string, description?: string, + author: string, + category: string, + avatarUrl: string, _links: Links }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index cf09eeb128..0b419cf542 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -54,5 +54,7 @@ public class MapperModule extends AbstractModule { bind(UIPluginDtoCollectionMapper.class); bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST); + + bind(PluginDtoMapper.class).to(Mappers.getMapper(PluginDtoMapper.class).getClass()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java deleted file mode 100644 index 3a4e8a1947..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDtoMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package sonia.scm.api.v2.resources; - -import sonia.scm.plugin.PluginCondition; -import sonia.scm.plugin.PluginInformation; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public class PluginCenterDtoMapper { - - public static Set<PluginInformation> map(List<PluginCenterDto.Plugin> plugins) { - HashSet<PluginInformation> pluginInformationSet = new HashSet<>(); - - for (PluginCenterDto.Plugin plugin : plugins) { - - PluginInformation pluginInformation = new PluginInformation(); - pluginInformation.setName(plugin.getName()); - pluginInformation.setAuthor(plugin.getAuthor()); - pluginInformation.setCategory(plugin.getCategory()); - pluginInformation.setVersion(plugin.getVersion()); - pluginInformation.setDescription(plugin.getDescription()); - - if (plugin.getConditions() != null) { - PluginCenterDto.Condition condition = plugin.getConditions(); - pluginInformation.setCondition(new PluginCondition(condition.getMinVersion(), condition.getOs(), condition.getArch())); - } - - pluginInformationSet.add(pluginInformation); - } - return pluginInformationSet; - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index 75386aed63..b096266537 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -12,11 +12,12 @@ import lombok.Setter; public class PluginDto extends HalRepresentation { private String name; - private String category; private String version; - private String author; - private String avatarUrl; + private String displayName; private String description; + private String author; + private String category; + private String avatarUrl; public PluginDto(Links links) { add(links); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 020e706e43..ca81edd7ff 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -1,6 +1,10 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; +import org.mapstruct.AfterMapping; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginState; import sonia.scm.plugin.PluginWrapper; @@ -10,20 +14,27 @@ import javax.inject.Inject; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; -public class PluginDtoMapper { - - private final ResourceLinks resourceLinks; +@Mapper +public abstract class PluginDtoMapper { @Inject - public PluginDtoMapper(ResourceLinks resourceLinks) { - this.resourceLinks = resourceLinks; - } + private ResourceLinks resourceLinks; public PluginDto map(PluginWrapper plugin) { return map(plugin.getPlugin().getInformation()); } - public PluginDto map(PluginInformation pluginInformation) { + public abstract PluginDto map(PluginInformation plugin); + + @AfterMapping + protected void appendCategory(@MappingTarget PluginDto dto) { + if (dto.getCategory() == null) { + dto.setCategory("Miscellaneous"); + } + } + + @ObjectFactory + public PluginDto createDto(PluginInformation pluginInformation) { Links.Builder linksBuilder; if (pluginInformation.getState() != null && pluginInformation.getState().equals(PluginState.AVAILABLE)) { linksBuilder = linkingTo() @@ -38,14 +49,6 @@ public class PluginDtoMapper { .self(pluginInformation.getName())); } - PluginDto pluginDto = new PluginDto(linksBuilder.build()); - pluginDto.setName(pluginInformation.getName()); - pluginDto.setCategory(pluginInformation.getCategory() != null ? pluginInformation.getCategory() : "Miscellaneous"); - pluginDto.setVersion(pluginInformation.getVersion()); - pluginDto.setAuthor(pluginInformation.getAuthor()); - pluginDto.setDescription(pluginInformation.getDescription()); - pluginDto.setAvatarUrl(pluginInformation.getAvatarUrl()); - - return pluginDto; + return new PluginDto(linksBuilder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 55b5dd9328..b718a43a81 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -46,7 +46,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.SCMContextProvider; -import sonia.scm.api.v2.resources.PluginCenterDto; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.config.ScmConfiguration; @@ -79,7 +78,7 @@ import javax.xml.bind.JAXB; import sonia.scm.net.ahc.AdvancedHttpClient; -import static sonia.scm.api.v2.resources.PluginCenterDtoMapper.*; +import static sonia.scm.plugin.PluginCenterDtoMapper.*; /** * TODO replace aether stuff. @@ -595,14 +594,8 @@ public class DefaultPluginManager implements PluginManager { synchronized (DefaultPluginManager.class) { - String pluginUrl = configuration.getPluginUrl(); - - pluginUrl = buildPluginUrl(pluginUrl); - - if (logger.isInfoEnabled()) - { - logger.info("fetch plugin informations from {}", pluginUrl); - } + String pluginUrl = buildPluginUrl(configuration.getPluginUrl()); + logger.info("fetch plugin information from {}", pluginUrl); if (REMOTE_PLUGINS_ENABLED && Util.isNotEmpty(pluginUrl)) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java similarity index 96% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java rename to scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index 423a0ba0d2..8bb48c8ceb 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -1,4 +1,4 @@ -package sonia.scm.api.v2.resources; +package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import lombok.AllArgsConstructor; @@ -45,10 +45,10 @@ public final class PluginCenterDto implements Serializable { public static class Plugin { private String name; + private String version; private String displayName; private String description; private String category; - private String version; private String author; private String avatarUrl; private String sha256; @@ -86,6 +86,5 @@ public final class PluginCenterDto implements Serializable { @Getter static class Link { private String href; - private boolean templated; } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java new file mode 100644 index 0000000000..ea445b3ede --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -0,0 +1,27 @@ +package sonia.scm.plugin; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Mapper +public interface PluginCenterDtoMapper { + + @Mapping(source = "conditions", target = "condition") + PluginInformation map(PluginCenterDto.Plugin plugin); + + PluginCondition map(PluginCenterDto.Condition condition); + + static Set<PluginInformation> map(List<PluginCenterDto.Plugin> dtos) { + PluginCenterDtoMapper mapper = Mappers.getMapper(PluginCenterDtoMapper.class); + Set<PluginInformation> plugins = new HashSet<>(); + for (PluginCenterDto.Plugin plugin : dtos) { + plugins.add(mapper.map(plugin)); + } + return plugins; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java new file mode 100644 index 0000000000..97b46603d3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java @@ -0,0 +1,88 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginState; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class PluginDtoMapperTest { + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("https://hitchhiker.com/")); + + @InjectMocks + private PluginDtoMapperImpl mapper; + + @Test + void shouldMapInformation() { + PluginInformation information = createPluginInformation(); + + PluginDto dto = mapper.map(information); + + assertThat(dto.getName()).isEqualTo("scm-cas-plugin"); + assertThat(dto.getVersion()).isEqualTo("1.0.0"); + assertThat(dto.getDisplayName()).isEqualTo("CAS"); + assertThat(dto.getAuthor()).isEqualTo("Sebastian Sdorra"); + assertThat(dto.getCategory()).isEqualTo("Authentication"); + assertThat(dto.getAvatarUrl()).isEqualTo("https://avatar.scm-manager.org/plugins/cas.png"); + } + + private PluginInformation createPluginInformation() { + PluginInformation information = new PluginInformation(); + information.setName("scm-cas-plugin"); + information.setVersion("1.0.0"); + information.setDisplayName("CAS"); + information.setAuthor("Sebastian Sdorra"); + information.setCategory("Authentication"); + information.setAvatarUrl("https://avatar.scm-manager.org/plugins/cas.png"); + return information; + } + + @Test + void shouldAppendInstalledSelfLink() { + PluginInformation information = createPluginInformation(); + information.setState(PluginState.INSTALLED); + + PluginDto dto = mapper.map(information); + assertThat(dto.getLinks().getLinkBy("self").get().getHref()) + .isEqualTo("https://hitchhiker.com/v2/plugins/installed/scm-cas-plugin"); + } + + @Test + void shouldAppendAvailableSelfLink() { + PluginInformation information = createPluginInformation(); + information.setState(PluginState.AVAILABLE); + + PluginDto dto = mapper.map(information); + assertThat(dto.getLinks().getLinkBy("self").get().getHref()) + .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/1.0.0"); + } + + @Test + void shouldAppendInstallLink() { + PluginInformation information = createPluginInformation(); + information.setState(PluginState.AVAILABLE); + + PluginDto dto = mapper.map(information); + assertThat(dto.getLinks().getLinkBy("install").get().getHref()) + .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/1.0.0/install"); + } + + @Test + void shouldReturnMiscellaneousIfCategoryIsNull() { + PluginInformation information = createPluginInformation(); + information.setCategory(null); + + PluginDto dto = mapper.map(information); + assertThat(dto.getCategory()).isEqualTo("Miscellaneous"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java similarity index 79% rename from scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java rename to scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index 1175526f75..66a90255b3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -1,8 +1,6 @@ -package sonia.scm.api.v2.resources; +package sonia.scm.plugin; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import sonia.scm.plugin.PluginInformation; import java.util.ArrayList; import java.util.Arrays; @@ -12,19 +10,11 @@ import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; -import static sonia.scm.api.v2.resources.PluginCenterDto.Condition; -import static sonia.scm.api.v2.resources.PluginCenterDto.Dependency; -import static sonia.scm.api.v2.resources.PluginCenterDto.Plugin; +import static sonia.scm.plugin.PluginCenterDto.Plugin; +import static sonia.scm.plugin.PluginCenterDto.*; class PluginCenterDtoMapperTest { - private PluginCenterDtoMapper pluginCenterDtoMapper; - - @BeforeEach - void initMapper() { - pluginCenterDtoMapper = new PluginCenterDtoMapper(); - } - @Test void shouldMapSinglePlugin() { Plugin plugin = new Plugin( @@ -82,10 +72,10 @@ class PluginCenterDtoMapperTest { Set<PluginInformation> resultSet = PluginCenterDtoMapper.map(Arrays.asList(plugin1, plugin2)); - List pluginsList = new ArrayList(resultSet); + List<PluginInformation> pluginsList = new ArrayList<>(resultSet); - PluginInformation pluginInformation1 = (PluginInformation) pluginsList.get(1); - PluginInformation pluginInformation2 = (PluginInformation) pluginsList.get(0); + PluginInformation pluginInformation1 = pluginsList.get(1); + PluginInformation pluginInformation2 = pluginsList.get(0); assertThat(pluginInformation1.getAuthor()).isEqualTo(plugin1.getAuthor()); assertThat(pluginInformation1.getVersion()).isEqualTo(plugin1.getVersion()); From f90060d9a8b4b2d7fb46a4c3cc5ebc2e415bba80 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Fri, 16 Aug 2019 14:50:51 +0200 Subject: [PATCH 081/135] update smp-maven-plugin to 1.0.0-alpha-6 --- pom.xml | 2 +- scm-webapp/pom.xml | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/pom.xml b/pom.xml index ba06282720..acd1f15303 100644 --- a/pom.xml +++ b/pom.xml @@ -437,7 +437,7 @@ <plugin> <groupId>sonia.scm.maven</groupId> <artifactId>smp-maven-plugin</artifactId> - <version>1.0.0-alpha-4</version> + <version>1.0.0-aplpha-6</version> </plugin> </plugins> </pluginManagement> diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml index a74d8dc429..73431b780e 100644 --- a/scm-webapp/pom.xml +++ b/scm-webapp/pom.xml @@ -464,32 +464,28 @@ <groupId>sonia.scm.maven</groupId> <artifactId>smp-maven-plugin</artifactId> <configuration> - <artifactItems> - <artifactItem> + <smpArtifacts> + <artifact> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-hg-plugin</artifactId> <version>${project.version}</version> - <type>smp</type> - </artifactItem> - <artifactItem> + </artifact> + <artifact> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-svn-plugin</artifactId> <version>${project.version}</version> - <type>smp</type> - </artifactItem> - <artifactItem> + </artifact> + <artifact> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-git-plugin</artifactId> <version>${project.version}</version> - <type>smp</type> - </artifactItem> - <artifactItem> + </artifact> + <artifact> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-legacy-plugin</artifactId> <version>${project.version}</version> - <type>smp</type> - </artifactItem> - </artifactItems> + </artifact> + </smpArtifacts> </configuration> <executions> <execution> From aa1452ab851164eb48b16c4306e329964965acbd Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Fri, 16 Aug 2019 15:56:15 +0200 Subject: [PATCH 082/135] Let git implement DIFF_RESULT command --- .../scm/repository/spi/GitRepositoryServiceProvider.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index f4b19d1e85..6870c85dea 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -59,7 +59,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.BLAME, Command.BROWSE, Command.CAT, - Command.DIFF, + Command.DIFF, + Command.DIFF_RESULT, Command.LOG, Command.TAGS, Command.BRANCHES, @@ -168,6 +169,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider return new GitDiffCommand(context, repository); } + @Override + public DiffResultCommand getDiffResultCommand() { + return new GitDiffResultCommand(context, repository); + } + /** * Method description * From bd5a8ba508d386650c411f8b14f285ae8aaeefdd Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Mon, 19 Aug 2019 09:23:38 +0200 Subject: [PATCH 083/135] Add css class to set cursor pointer --- scm-ui/styles/scm.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scm-ui/styles/scm.scss b/scm-ui/styles/scm.scss index ddb2a748e5..48d66e9768 100644 --- a/scm-ui/styles/scm.scss +++ b/scm-ui/styles/scm.scss @@ -526,5 +526,9 @@ form .field:not(.is-grouped) { } } +// cursor +.has-cursor-pointer { + cursor: pointer; +} @import "bulma-popover/css/bulma-popover"; From 7d75bd76a19316e9331aab500d3e0562cd800fcd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 19 Aug 2019 09:31:33 +0200 Subject: [PATCH 084/135] fix typo in smp-maven-plugin version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index acd1f15303..3166c6ec75 100644 --- a/pom.xml +++ b/pom.xml @@ -437,7 +437,7 @@ <plugin> <groupId>sonia.scm.maven</groupId> <artifactId>smp-maven-plugin</artifactId> - <version>1.0.0-aplpha-6</version> + <version>1.0.0-alpha-6</version> </plugin> </plugins> </pluginManagement> From 07e70794fbed93592c40ec01d019672985008801 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 19 Aug 2019 09:32:18 +0200 Subject: [PATCH 085/135] improve plugin information for core plugins --- scm-plugins/scm-git-plugin/pom.xml | 1 - .../scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml | 4 +++- scm-plugins/scm-hg-plugin/pom.xml | 1 - .../scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml | 4 +++- scm-plugins/scm-legacy-plugin/pom.xml | 1 - .../src/main/resources/META-INF/scm/plugin.xml | 4 +++- scm-plugins/scm-svn-plugin/pom.xml | 1 - .../scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml | 4 +++- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/scm-plugins/scm-git-plugin/pom.xml b/scm-plugins/scm-git-plugin/pom.xml index 7f5531691f..ac1f007394 100644 --- a/scm-plugins/scm-git-plugin/pom.xml +++ b/scm-plugins/scm-git-plugin/pom.xml @@ -10,7 +10,6 @@ </parent> <artifactId>scm-git-plugin</artifactId> - <name>Git</name> <packaging>smp</packaging> <url>https://bitbucket.org/sdorra/scm-manager</url> <description>Plugin for the version control system Git</description> diff --git a/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml index 1706603e8a..ba1d625fb4 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -46,8 +46,10 @@ <scm-version>2</scm-version> <information> - <author>Sebastian Sdorra</author> + <displayName>Git</displayName> + <author>Cloudogu GmbH</author> <category>Source Code Management</category> + <avatarUrl>/images/git-logo.png</avatarUrl> </information> <conditions> diff --git a/scm-plugins/scm-hg-plugin/pom.xml b/scm-plugins/scm-hg-plugin/pom.xml index ace7642e91..e5decb0567 100644 --- a/scm-plugins/scm-hg-plugin/pom.xml +++ b/scm-plugins/scm-hg-plugin/pom.xml @@ -10,7 +10,6 @@ </parent> <artifactId>scm-hg-plugin</artifactId> - <name>Mercurial</name> <packaging>smp</packaging> <url>https://bitbucket.org/sdorra/scm-manager</url> <description>Plugin for the version control system Mercurial</description> diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml index 90d4270a40..352192121f 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -46,8 +46,10 @@ jo <scm-version>2</scm-version> <information> - <author>Sebastian Sdorra</author> + <displayName>Mercurial</displayName> + <author>Cloudogu GmbH</author> <category>Source Code Management</category> + <avatarUrl>/images/hg-logo.png</avatarUrl> </information> <conditions> diff --git a/scm-plugins/scm-legacy-plugin/pom.xml b/scm-plugins/scm-legacy-plugin/pom.xml index 5f876beaa2..1a12234014 100644 --- a/scm-plugins/scm-legacy-plugin/pom.xml +++ b/scm-plugins/scm-legacy-plugin/pom.xml @@ -8,7 +8,6 @@ </parent> <artifactId>scm-legacy-plugin</artifactId> - <name>Legacy</name> <description>Support migrated repository urls and v1 passwords</description> <version>2.0.0-SNAPSHOT</version> <packaging>smp</packaging> diff --git a/scm-plugins/scm-legacy-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-legacy-plugin/src/main/resources/META-INF/scm/plugin.xml index f8a3c8c7b4..2a6b553cdf 100644 --- a/scm-plugins/scm-legacy-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-legacy-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -46,7 +46,9 @@ <scm-version>2</scm-version> <information> - <author>Sebastian Sdorra</author> + <displayName>Legacy</displayName> + <author>Cloudogu GmbH</author> + <category>Legacy Support</category> </information> <conditions> diff --git a/scm-plugins/scm-svn-plugin/pom.xml b/scm-plugins/scm-svn-plugin/pom.xml index b924997711..83da627eb9 100644 --- a/scm-plugins/scm-svn-plugin/pom.xml +++ b/scm-plugins/scm-svn-plugin/pom.xml @@ -10,7 +10,6 @@ </parent> <artifactId>scm-svn-plugin</artifactId> - <name>Subversion</name> <packaging>smp</packaging> <url>https://bitbucket.org/sdorra/scm-manager</url> <description>Plugin for the version control system Subversion</description> diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml index 7a36cc1486..5e941e98e1 100644 --- a/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml +++ b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/plugin.xml @@ -46,8 +46,10 @@ <scm-version>2</scm-version> <information> - <author>Sebastian Sdorra</author> + <displayName>Subversion</displayName> + <author>Cloudogu GmbH</author> <category>Source Code Management</category> + <avatarUrl>/images/svn-logo.gif</avatarUrl> </information> <conditions> From 9ee56f8e38226cf73b067e4e0ff254c38ea7737c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 19 Aug 2019 09:32:37 +0200 Subject: [PATCH 086/135] use displayName if available --- scm-ui/src/admin/plugins/components/PluginEntry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index 047ce5492e..7aaeb3f67f 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -54,7 +54,7 @@ class PluginEntry extends React.Component<Props> { <CardColumn link="#" avatar={avatar} - title={plugin.name} + title={plugin.displayName ? plugin.displayName : plugin.name} description={plugin.description} contentRight={contentRight} footerLeft={footerLeft} From e9f56f201bd18a81e3ff97653d1b51a5ac656a36 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Mon, 19 Aug 2019 10:01:05 +0200 Subject: [PATCH 087/135] Make Diff collapsible optional and render type only if present --- .../packages/ui-components/src/repos/DiffFile.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/repos/DiffFile.js b/scm-ui-components/packages/ui-components/src/repos/DiffFile.js index a921a4a0a4..3f849dff6b 100644 --- a/scm-ui-components/packages/ui-components/src/repos/DiffFile.js +++ b/scm-ui-components/packages/ui-components/src/repos/DiffFile.js @@ -46,6 +46,7 @@ const styles = { type Props = DiffObjectProps & { file: File, + collapsible: true, // context props classes: any, t: string => string @@ -66,9 +67,11 @@ class DiffFile extends React.Component<Props, State> { } toggleCollapse = () => { - this.setState(state => ({ - collapsed: !state.collapsed - })); + if (this.props.collapsable) { + this.setState(state => ({ + collapsed: !state.collapsed + })); + } }; toggleSideBySide = () => { @@ -173,6 +176,9 @@ class DiffFile extends React.Component<Props, State> { renderChangeTag = (file: any) => { const { t, classes } = this.props; + if (!file.type) { + return; + } const key = "diff.changes." + file.type; let value = t(key); if (key === value) { @@ -205,6 +211,7 @@ class DiffFile extends React.Component<Props, State> { file, fileControlFactory, fileAnnotationFactory, + collapsible, classes, t } = this.props; @@ -227,6 +234,7 @@ class DiffFile extends React.Component<Props, State> { </div> ); } + const collapseIcon = collapsible? <i className={icon} />: null; const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) @@ -240,7 +248,7 @@ class DiffFile extends React.Component<Props, State> { onClick={this.toggleCollapse} title={this.hoverFileTitle(file)} > - <i className={icon} /> + {collapseIcon} <span className={classNames("is-ellipsis-overflow", classes.title)} > From e86fb514907e55fb885a8c9ef1befd72b3181e24 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Mon, 19 Aug 2019 11:43:34 +0200 Subject: [PATCH 088/135] Fix typo --- scm-ui/public/locales/de/repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/public/locales/de/repos.json b/scm-ui/public/locales/de/repos.json index 4f85ce0fde..40c103fdb0 100644 --- a/scm-ui/public/locales/de/repos.json +++ b/scm-ui/public/locales/de/repos.json @@ -172,7 +172,7 @@ } }, "diff": { - "sideBySide": "Zweispalitg", + "sideBySide": "Zweispaltig", "combined": "Kombiniert" } } From be5ed1e50c5824a4e3725439654bfdb10a5f23ab Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 19 Aug 2019 12:56:27 +0200 Subject: [PATCH 089/135] reactivate plugin center url configuration --- scm-ui/public/locales/de/config.json | 4 ++-- scm-ui/public/locales/en/config.json | 4 ++-- .../src/admin/components/form/GeneralSettings.js | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scm-ui/public/locales/de/config.json b/scm-ui/public/locales/de/config.json index a94965fa68..0bc220515a 100644 --- a/scm-ui/public/locales/de/config.json +++ b/scm-ui/public/locales/de/config.json @@ -41,7 +41,7 @@ "date-format": "Datumsformat", "anonymous-access-enabled": "Anonyme Zugriffe erlauben", "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", - "plugin-url": "Plugin URL", + "plugin-url": "Plugin Center URL", "enabled-xsrf-protection": "XSRF Protection aktivieren", "namespace-strategy": "Namespace Strategie", "login-info-url": "Login Info URL" @@ -55,7 +55,7 @@ "help": { "realmDescriptionHelpText": "Beschreibung des Authentication Realm.", "dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.", - "pluginRepositoryHelpText": "Die URL des Plugin Repositories. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", + "pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", "enableRepositoryArchiveHelpText": "Repository Archive aktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", "disableGroupingGridHelpText": "Repository Gruppen deaktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index 894d0563ba..ce0f7252df 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -41,7 +41,7 @@ "date-format": "Date Format", "anonymous-access-enabled": "Anonymous Access Enabled", "skip-failed-authenticators": "Skip Failed Authenticators", - "plugin-url": "Plugin URL", + "plugin-url": "Plugin Center URL", "enabled-xsrf-protection": "Enabled XSRF Protection", "namespace-strategy": "Namespace Strategy", "login-info-url": "Login Info URL" @@ -55,7 +55,7 @@ "help": { "realmDescriptionHelpText": "Enter authentication realm description.", "dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.", - "pluginRepositoryHelpText": "The url of the plugin repository. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", + "pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", "enableForwardingHelpText": "Enable mod_proxy port forwarding.", "enableRepositoryArchiveHelpText": "Enable repository archives. A complete page reload is required after a change of this value.", "disableGroupingGridHelpText": "Disable repository Groups. A complete page reload is required after a change of this value.", diff --git a/scm-ui/src/admin/components/form/GeneralSettings.js b/scm-ui/src/admin/components/form/GeneralSettings.js index a2fb1127af..842d0ed7f4 100644 --- a/scm-ui/src/admin/components/form/GeneralSettings.js +++ b/scm-ui/src/admin/components/form/GeneralSettings.js @@ -29,6 +29,7 @@ class GeneralSettings extends React.Component<Props> { t, realmDescription, loginInfoUrl, + pluginUrl, enabledXsrfProtection, namespaceStrategy, hasUpdatePermission, @@ -78,6 +79,17 @@ class GeneralSettings extends React.Component<Props> { /> </div> </div> + <div className="columns"> + <div className="column is-half"> + <InputField + label={t("general-settings.plugin-url")} + onChange={this.handlePluginCenterUrlChange} + value={pluginUrl} + disabled={!hasUpdatePermission} + helpText={t("help.pluginUrlHelpText")} + /> + </div> + </div> </div> ); } @@ -85,7 +97,6 @@ class GeneralSettings extends React.Component<Props> { handleLoginInfoUrlChange = (value: string) => { this.props.onChange(true, value, "loginInfoUrl"); }; - handleRealmDescriptionChange = (value: string) => { this.props.onChange(true, value, "realmDescription"); }; @@ -95,6 +106,9 @@ class GeneralSettings extends React.Component<Props> { handleNamespaceStrategyChange = (value: string) => { this.props.onChange(true, value, "namespaceStrategy"); }; + handlePluginCenterUrlChange = (value: string) => { + this.props.onChange(true, value, "pluginUrl"); + }; } export default translate("config")(GeneralSettings); From e1dcd2301f9a47c0d626bceeb6c17e8a71613521 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Mon, 19 Aug 2019 12:14:03 +0000 Subject: [PATCH 090/135] Close branch feature/plugin_center From 056a81d9f9266b4014c229c47a6b798d9907d447 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 07:53:17 +0200 Subject: [PATCH 091/135] fixed dependency mapping --- .../main/java/sonia/scm/plugin/PluginCenterDto.java | 13 +++---------- .../sonia/scm/plugin/PluginCenterDtoMapperTest.java | 7 ++++--- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index 8bb48c8ceb..afb8e739a0 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -11,6 +11,7 @@ import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; import java.util.List; import java.util.Map; +import java.util.Set; @XmlRootElement @XmlAccessorType(XmlAccessType.FIELD) @@ -56,8 +57,8 @@ public final class PluginCenterDto implements Serializable { @XmlElement(name = "conditions") private Condition conditions; - @XmlElement(name = "dependecies") - private Dependency dependencies; + @XmlElement(name = "dependencies") + private Set<String> dependencies; @XmlElement(name = "_links") private Map<String, Link> links; @@ -74,14 +75,6 @@ public final class PluginCenterDto implements Serializable { private String minVersion; } - @XmlAccessorType(XmlAccessType.FIELD) - @XmlRootElement(name = "dependencies") - @Getter - @AllArgsConstructor - static class Dependency { - private String name; - } - @XmlAccessorType(XmlAccessType.FIELD) @Getter static class Link { diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index 66a90255b3..831e847843 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -1,5 +1,6 @@ package sonia.scm.plugin; +import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; import java.util.ArrayList; @@ -27,7 +28,7 @@ class PluginCenterDtoMapperTest { "http://avatar.url", "555000444", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), - new Dependency("scm-review-plugin"), + ImmutableSet.of("scm-review-plugin"), new HashMap<>()); PluginInformation result = PluginCenterDtoMapper.map(Collections.singletonList(plugin)).iterator().next(); @@ -54,7 +55,7 @@ class PluginCenterDtoMapperTest { "https://avatar.url", "12345678aa", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), - new Dependency("scm-review-plugin"), + ImmutableSet.of("scm-review-plugin"), new HashMap<>()); Plugin plugin2 = new Plugin( @@ -67,7 +68,7 @@ class PluginCenterDtoMapperTest { "http://avatar.url", "555000444", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), - new Dependency("scm-review-plugin"), + ImmutableSet.of("scm-review-plugin"), new HashMap<>()); Set<PluginInformation> resultSet = PluginCenterDtoMapper.map(Arrays.asList(plugin1, plugin2)); From ae19ad9327adb0fb4030bcee5bd15bf72214c7f1 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 07:54:00 +0200 Subject: [PATCH 092/135] renamed PluginWrapper to InstalledPlugin --- ...luginWrapper.java => InstalledPlugin.java} | 6 ++-- .../java/sonia/scm/plugin/PluginLoader.java | 2 +- .../v2/resources/InstalledPluginResource.java | 4 +-- .../resources/PluginDtoCollectionMapper.java | 4 +-- .../scm/api/v2/resources/PluginDtoMapper.java | 4 +-- .../UIPluginDtoCollectionMapper.java | 4 +-- .../api/v2/resources/UIPluginDtoMapper.java | 6 ++-- .../api/v2/resources/UIPluginResource.java | 6 ++-- .../sonia/scm/lifecycle/PluginBootstrap.java | 8 ++--- .../sonia/scm/plugin/DefaultPluginLoader.java | 6 ++-- .../scm/plugin/DefaultPluginManager.java | 2 +- .../plugin/DefaultUberWebResourceLoader.java | 10 +++--- .../java/sonia/scm/plugin/PluginNode.java | 6 ++-- .../sonia/scm/plugin/PluginProcessor.java | 26 +++++++-------- .../sonia/scm/plugin/PluginsInternal.java | 10 +++--- .../sonia/scm/plugin/UberClassLoader.java | 10 +++--- .../InstalledPluginResourceTest.java | 14 ++++---- .../api/v2/resources/UIRootResourceTest.java | 8 ++--- .../DefaultUberWebResourceLoaderTest.java | 28 ++++++++-------- .../sonia/scm/plugin/PluginProcessorTest.java | 32 +++++++++---------- 20 files changed, 98 insertions(+), 98 deletions(-) rename scm-core/src/main/java/sonia/scm/plugin/{PluginWrapper.java => InstalledPlugin.java} (95%) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginWrapper.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java similarity index 95% rename from scm-core/src/main/java/sonia/scm/plugin/PluginWrapper.java rename to scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java index 46c3a4a980..8e93953074 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginWrapper.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -42,7 +42,7 @@ import java.nio.file.Path; * @author Sebastian Sdorra * @since 2.0.0 */ -public final class PluginWrapper +public final class InstalledPlugin { /** @@ -53,8 +53,8 @@ public final class PluginWrapper * @param webResourceLoader web resource loader * @param directory plugin directory */ - public PluginWrapper(Plugin plugin, ClassLoader classLoader, - WebResourceLoader webResourceLoader, Path directory) + public InstalledPlugin(Plugin plugin, ClassLoader classLoader, + WebResourceLoader webResourceLoader, Path directory) { this.plugin = plugin; this.classLoader = classLoader; diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java b/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java index 2d65d1cc98..e82d945024 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java @@ -68,7 +68,7 @@ public interface PluginLoader * * @return */ - public Collection<PluginWrapper> getInstalledPlugins(); + public Collection<InstalledPlugin> getInstalledPlugins(); /** * Returns a {@link ClassLoader} which is able to load classes and resources diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index f10912e5ac..230b34171b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -7,7 +7,7 @@ import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.web.VndMediaType; import javax.inject.Inject; @@ -53,7 +53,7 @@ public class InstalledPluginResource { @Produces(VndMediaType.PLUGIN_COLLECTION) public Response getInstalledPlugins() { PluginPermissions.read().check(); - List<PluginWrapper> plugins = new ArrayList<>(pluginLoader.getInstalledPlugins()); + List<InstalledPlugin> plugins = new ArrayList<>(pluginLoader.getInstalledPlugins()); return Response.ok(collectionMapper.map(plugins)).build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index 5d8746c211..c835362df7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -5,7 +5,7 @@ import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import sonia.scm.plugin.PluginInformation; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import java.util.Collection; import java.util.List; @@ -25,7 +25,7 @@ public class PluginDtoCollectionMapper { this.mapper = mapper; } - public HalRepresentation map(List<PluginWrapper> plugins) { + public HalRepresentation map(List<InstalledPlugin> plugins) { List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList()); return new HalRepresentation(createInstalledPluginsLinks(), embedDtos(dtos)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index ca81edd7ff..4967c55b31 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -7,7 +7,7 @@ import org.mapstruct.MappingTarget; import org.mapstruct.ObjectFactory; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginState; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import javax.inject.Inject; @@ -20,7 +20,7 @@ public abstract class PluginDtoMapper { @Inject private ResourceLinks resourceLinks; - public PluginDto map(PluginWrapper plugin) { + public PluginDto map(InstalledPlugin plugin) { return map(plugin.getPlugin().getInformation()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoCollectionMapper.java index f032650d8a..2cb15f7904 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoCollectionMapper.java @@ -4,7 +4,7 @@ import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import java.util.Collection; import java.util.List; @@ -24,7 +24,7 @@ public class UIPluginDtoCollectionMapper { this.mapper = mapper; } - public HalRepresentation map(Collection<PluginWrapper> plugins) { + public HalRepresentation map(Collection<InstalledPlugin> plugins) { List<UIPluginDto> dtos = plugins.stream().map(mapper::map).collect(toList()); return new HalRepresentation(createLinks(), embedDtos(dtos)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java index 10ae79b5bf..8a2b6cb0c1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java @@ -2,7 +2,7 @@ package sonia.scm.api.v2.resources; import com.google.common.base.Strings; import de.otto.edison.hal.Links; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.util.HttpUtil; import javax.inject.Inject; @@ -25,7 +25,7 @@ public class UIPluginDtoMapper { this.request = request; } - public UIPluginDto map(PluginWrapper plugin) { + public UIPluginDto map(InstalledPlugin plugin) { UIPluginDto dto = new UIPluginDto( plugin.getPlugin().getInformation().getName(), getScriptResources(plugin) @@ -40,7 +40,7 @@ public class UIPluginDtoMapper { return dto; } - private Set<String> getScriptResources(PluginWrapper wrapper) { + private Set<String> getScriptResources(InstalledPlugin wrapper) { Set<String> scriptResources = wrapper.getPlugin().getResources().getScriptResources(); if (scriptResources != null) { return scriptResources.stream() diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java index b83f5310e3..d34bcbe3ba 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java @@ -4,7 +4,7 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import sonia.scm.plugin.PluginLoader; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.security.AllowAnonymousAccess; import sonia.scm.web.VndMediaType; @@ -46,7 +46,7 @@ public class UIPluginResource { @TypeHint(CollectionDto.class) @Produces(VndMediaType.UI_PLUGIN_COLLECTION) public Response getInstalledPlugins() { - List<PluginWrapper> plugins = pluginLoader.getInstalledPlugins() + List<InstalledPlugin> plugins = pluginLoader.getInstalledPlugins() .stream() .filter(this::filter) .collect(Collectors.toList()); @@ -85,7 +85,7 @@ public class UIPluginResource { } } - private boolean filter(PluginWrapper plugin) { + private boolean filter(InstalledPlugin plugin) { return plugin.getPlugin().getResources() != null; } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java index e19d41cc69..3d7e6ec0b2 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java @@ -13,7 +13,7 @@ import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginException; import sonia.scm.plugin.PluginLoadException; import sonia.scm.plugin.PluginLoader; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.PluginsInternal; import sonia.scm.plugin.SmpArchive; import sonia.scm.util.IOUtil; @@ -43,7 +43,7 @@ public final class PluginBootstrap { private final ClassLoaderLifeCycle classLoaderLifeCycle; private final ServletContext servletContext; - private final Set<PluginWrapper> plugins; + private final Set<InstalledPlugin> plugins; private final PluginLoader pluginLoader; PluginBootstrap(ServletContext servletContext, ClassLoaderLifeCycle classLoaderLifeCycle) { @@ -58,7 +58,7 @@ public final class PluginBootstrap { return pluginLoader; } - public Set<PluginWrapper> getPlugins() { + public Set<InstalledPlugin> getPlugins() { return plugins; } @@ -66,7 +66,7 @@ public final class PluginBootstrap { return new DefaultPluginLoader(servletContext, classLoaderLifeCycle.getBootstrapClassLoader(), plugins); } - private Set<PluginWrapper> collectPlugins() { + private Set<InstalledPlugin> collectPlugins() { try { File pluginDirectory = getPluginDirectory(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java index cc3ef01c56..1e4a4b92d7 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java @@ -85,7 +85,7 @@ public class DefaultPluginLoader implements PluginLoader * @param installedPlugins */ public DefaultPluginLoader(ServletContext servletContext, ClassLoader parent, - Set<PluginWrapper> installedPlugins) + Set<InstalledPlugin> installedPlugins) { this.installedPlugins = installedPlugins; this.uberClassLoader = new UberClassLoader(parent, installedPlugins); @@ -141,7 +141,7 @@ public class DefaultPluginLoader implements PluginLoader * @return */ @Override - public Collection<PluginWrapper> getInstalledPlugins() + public Collection<InstalledPlugin> getInstalledPlugins() { return installedPlugins; } @@ -227,7 +227,7 @@ public class DefaultPluginLoader implements PluginLoader private final ExtensionProcessor extensionProcessor; /** Field description */ - private final Set<PluginWrapper> installedPlugins; + private final Set<InstalledPlugin> installedPlugins; /** Field description */ private final Set<ScmModule> modules; diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index b718a43a81..70cbab23ff 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -129,7 +129,7 @@ public class DefaultPluginManager implements PluginManager this.httpClient = httpClient; installedPlugins = new HashMap<>(); - for (PluginWrapper wrapper : pluginLoader.getInstalledPlugins()) + for (InstalledPlugin wrapper : pluginLoader.getInstalledPlugins()) { Plugin plugin = wrapper.getPlugin(); PluginInformation info = plugin.getInformation(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultUberWebResourceLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultUberWebResourceLoader.java index 25b8390e53..c0500245a6 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultUberWebResourceLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultUberWebResourceLoader.java @@ -71,11 +71,11 @@ public class DefaultUberWebResourceLoader implements UberWebResourceLoader //~--- constructors --------------------------------------------------------- - public DefaultUberWebResourceLoader(ServletContext servletContext, Iterable<PluginWrapper> plugins) { + public DefaultUberWebResourceLoader(ServletContext servletContext, Iterable<InstalledPlugin> plugins) { this(servletContext, plugins, SCMContext.getContext().getStage()); } - public DefaultUberWebResourceLoader(ServletContext servletContext, Iterable<PluginWrapper> plugins, Stage stage) { + public DefaultUberWebResourceLoader(ServletContext servletContext, Iterable<InstalledPlugin> plugins, Stage stage) { this.servletContext = servletContext; this.plugins = plugins; this.cache = createCache(stage); @@ -153,7 +153,7 @@ public class DefaultUberWebResourceLoader implements UberWebResourceLoader resources.add(ctxResource); } - for (PluginWrapper wrapper : plugins) + for (InstalledPlugin wrapper : plugins) { URL resource = nonDirectory(wrapper.getWebResourceLoader().getResource(path)); @@ -205,7 +205,7 @@ public class DefaultUberWebResourceLoader implements UberWebResourceLoader if (resource == null) { - for (PluginWrapper wrapper : plugins) + for (InstalledPlugin wrapper : plugins) { resource = nonDirectory(wrapper.getWebResourceLoader().getResource(path)); @@ -259,7 +259,7 @@ public class DefaultUberWebResourceLoader implements UberWebResourceLoader private final Cache<String, URL> cache; /** Field description */ - private final Iterable<PluginWrapper> plugins; + private final Iterable<InstalledPlugin> plugins; /** Field description */ private final ServletContext servletContext; diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java index 8bc4f47658..94a048770f 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java @@ -157,7 +157,7 @@ public final class PluginNode * * @return */ - public PluginWrapper getWrapper() + public InstalledPlugin getWrapper() { return wrapper; } @@ -170,7 +170,7 @@ public final class PluginNode * * @param wrapper */ - public void setWrapper(PluginWrapper wrapper) + public void setWrapper(InstalledPlugin wrapper) { this.wrapper = wrapper; } @@ -192,5 +192,5 @@ public final class PluginNode private final ExplodedSmp plugin; /** Field description */ - private PluginWrapper wrapper; + private InstalledPlugin wrapper; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index b91ee9b1ee..3a168f43d2 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -160,7 +160,7 @@ public final class PluginProcessor * * @throws IOException */ - public Set<PluginWrapper> collectPlugins(ClassLoader classLoader) + public Set<InstalledPlugin> collectPlugins(ClassLoader classLoader) throws IOException { logger.info("collect plugins"); @@ -187,7 +187,7 @@ public final class PluginProcessor logger.trace("create plugin wrappers and build classloaders"); - Set<PluginWrapper> wrappers = createPluginWrappers(classLoader, rootNodes); + Set<InstalledPlugin> wrappers = createPluginWrappers(classLoader, rootNodes); logger.debug("collected {} plugins", wrappers.size()); @@ -204,7 +204,7 @@ public final class PluginProcessor * * @throws IOException */ - private void appendPluginWrapper(Set<PluginWrapper> plugins, + private void appendPluginWrapper(Set<InstalledPlugin> plugins, ClassLoader classLoader, PluginNode node) throws IOException { @@ -217,7 +217,7 @@ public final class PluginProcessor for (PluginNode parent : node.getParents()) { - PluginWrapper wrapper = parent.getWrapper(); + InstalledPlugin wrapper = parent.getWrapper(); if (wrapper != null) { @@ -236,7 +236,7 @@ public final class PluginProcessor } - PluginWrapper plugin = + InstalledPlugin plugin = createPluginWrapper(createParentPluginClassLoader(classLoader, parents), smp); @@ -257,7 +257,7 @@ public final class PluginProcessor * * @throws IOException */ - private void appendPluginWrappers(Set<PluginWrapper> plugins, + private void appendPluginWrappers(Set<InstalledPlugin> plugins, ClassLoader classLoader, List<PluginNode> nodes) throws IOException { @@ -474,11 +474,11 @@ public final class PluginProcessor * * @throws IOException */ - private PluginWrapper createPluginWrapper(ClassLoader classLoader, - ExplodedSmp smp) + private InstalledPlugin createPluginWrapper(ClassLoader classLoader, + ExplodedSmp smp) throws IOException { - PluginWrapper wrapper = null; + InstalledPlugin wrapper = null; Path directory = smp.getPath(); Path descriptor = directory.resolve(PluginConstants.FILE_DESCRIPTOR); @@ -490,7 +490,7 @@ public final class PluginProcessor WebResourceLoader resourceLoader = createWebResourceLoader(directory); - wrapper = new PluginWrapper(plugin, cl, resourceLoader, directory); + wrapper = new InstalledPlugin(plugin, cl, resourceLoader, directory); } else { @@ -512,11 +512,11 @@ public final class PluginProcessor * * @throws IOException */ - private Set<PluginWrapper> createPluginWrappers(ClassLoader classLoader, - List<PluginNode> rootNodes) + private Set<InstalledPlugin> createPluginWrappers(ClassLoader classLoader, + List<PluginNode> rootNodes) throws IOException { - Set<PluginWrapper> plugins = Sets.newHashSet(); + Set<InstalledPlugin> plugins = Sets.newHashSet(); appendPluginWrappers(plugins, classLoader, rootNodes); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java index 0354ded11a..fb916ee5ec 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java @@ -87,8 +87,8 @@ public final class PluginsInternal * * @throws IOException */ - public static Set<PluginWrapper> collectPlugins(ClassLoaderLifeCycle classLoaderLifeCycle, - Path directory) + public static Set<InstalledPlugin> collectPlugins(ClassLoaderLifeCycle classLoaderLifeCycle, + Path directory) throws IOException { PluginProcessor processor = new PluginProcessor(classLoaderLifeCycle, directory); @@ -159,7 +159,7 @@ public final class PluginsInternal * * @return */ - public static Iterable<Plugin> unwrap(Iterable<PluginWrapper> wrapped) + public static Iterable<Plugin> unwrap(Iterable<InstalledPlugin> wrapped) { return Iterables.transform(wrapped, new Unwrap()); } @@ -188,7 +188,7 @@ public final class PluginsInternal * @version Enter version here..., 14/06/05 * @author Enter your name here... */ - private static class Unwrap implements Function<PluginWrapper, Plugin> + private static class Unwrap implements Function<InstalledPlugin, Plugin> { /** @@ -200,7 +200,7 @@ public final class PluginsInternal * @return */ @Override - public Plugin apply(PluginWrapper wrapper) + public Plugin apply(InstalledPlugin wrapper) { return wrapper.getPlugin(); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java index 311cb9e879..62b1073a85 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java @@ -65,7 +65,7 @@ public final class UberClassLoader extends ClassLoader * @param parent * @param plugins */ - public UberClassLoader(ClassLoader parent, Iterable<PluginWrapper> plugins) + public UberClassLoader(ClassLoader parent, Iterable<InstalledPlugin> plugins) { super(parent); this.plugins = plugins; @@ -87,7 +87,7 @@ public final class UberClassLoader extends ClassLoader } private Class<?> findClassInPlugins(String name) throws ClassNotFoundException { - for (PluginWrapper plugin : plugins) { + for (InstalledPlugin plugin : plugins) { Class<?> clazz = findClass(plugin.getClassLoader(), name); if (clazz != null) { return clazz; @@ -119,7 +119,7 @@ public final class UberClassLoader extends ClassLoader { URL url = null; - for (PluginWrapper plugin : plugins) + for (InstalledPlugin plugin : plugins) { ClassLoader cl = plugin.getClassLoader(); @@ -149,7 +149,7 @@ public final class UberClassLoader extends ClassLoader { List<URL> urls = Lists.newArrayList(); - for (PluginWrapper plugin : plugins) + for (InstalledPlugin plugin : plugins) { ClassLoader cl = plugin.getClassLoader(); @@ -194,5 +194,5 @@ public final class UberClassLoader extends ClassLoader Maps.newConcurrentMap(); /** Field description */ - private final Iterable<PluginWrapper> plugins; + private final Iterable<InstalledPlugin> plugins; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index a81eadadb8..098a12880a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -20,7 +20,7 @@ import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginState; -import sonia.scm.plugin.PluginWrapper; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.web.VndMediaType; import javax.inject.Provider; @@ -86,9 +86,9 @@ class InstalledPluginResourceTest { @Test void getInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { - PluginWrapper pluginWrapper = new PluginWrapper(null, null, null, null); - when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(pluginWrapper)); - when(collectionMapper.map(Collections.singletonList(pluginWrapper))).thenReturn(new MockedResultDto()); + InstalledPlugin installedPlugin = new InstalledPlugin(null, null, null, null); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(installedPlugin)); + when(collectionMapper.map(Collections.singletonList(installedPlugin))).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); request.accept(VndMediaType.PLUGIN_COLLECTION); @@ -107,12 +107,12 @@ class InstalledPluginResourceTest { pluginInformation.setName("pluginName"); pluginInformation.setState(PluginState.INSTALLED); Plugin plugin = new Plugin(2, pluginInformation, null, null, false, null); - PluginWrapper pluginWrapper = new PluginWrapper(plugin, null, null, null); - when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(pluginWrapper)); + InstalledPlugin installedPlugin = new InstalledPlugin(plugin, null, null, null); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(installedPlugin)); PluginDto pluginDto = new PluginDto(); pluginDto.setName("pluginName"); - when(mapper.map(pluginWrapper)).thenReturn(pluginDto); + when(mapper.map(installedPlugin)).thenReturn(pluginDto); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed/pluginName"); request.accept(VndMediaType.PLUGIN); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java index b2dafc8cfe..2dc195808e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java @@ -170,7 +170,7 @@ public class UIRootResourceTest { assertTrue(response.getContentAsString().contains("/scm/my/bundle.js")); } - private void mockPlugins(PluginWrapper... plugins) { + private void mockPlugins(InstalledPlugin... plugins) { when(pluginLoader.getInstalledPlugins()).thenReturn(Lists.newArrayList(plugins)); } @@ -180,12 +180,12 @@ public class UIRootResourceTest { return new PluginResources(scripts, styles); } - private PluginWrapper mockPlugin(String id) { + private InstalledPlugin mockPlugin(String id) { return mockPlugin(id, id, null); } - private PluginWrapper mockPlugin(String id, String name, PluginResources pluginResources) { - PluginWrapper wrapper = mock(PluginWrapper.class); + private InstalledPlugin mockPlugin(String id, String name, PluginResources pluginResources) { + InstalledPlugin wrapper = mock(InstalledPlugin.class); when(wrapper.getId()).thenReturn(id); Plugin plugin = mock(Plugin.class); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java index 9e5ebccbfd..7cb534c7ba 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java @@ -102,7 +102,7 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase public void testGetResourceFromCache() { DefaultUberWebResourceLoader resourceLoader = new DefaultUberWebResourceLoader(servletContext, - new ArrayList<PluginWrapper>(), Stage.PRODUCTION); + new ArrayList<InstalledPlugin>(), Stage.PRODUCTION); resourceLoader.getCache().put("/myresource", GITHUB); @@ -131,8 +131,8 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase { File directory = temp.newFolder(); File file = file(directory, "myresource"); - PluginWrapper wrapper = createPluginWrapper(directory); - List<PluginWrapper> plugins = Lists.newArrayList(wrapper); + InstalledPlugin wrapper = createPluginWrapper(directory); + List<InstalledPlugin> plugins = Lists.newArrayList(wrapper); WebResourceLoader resourceLoader = new DefaultUberWebResourceLoader(servletContext, plugins); @@ -170,8 +170,8 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase File directory = temp.newFolder(); File file = file(directory, "myresource"); - PluginWrapper wrapper = createPluginWrapper(directory); - List<PluginWrapper> plugins = Lists.newArrayList(wrapper); + InstalledPlugin wrapper = createPluginWrapper(directory); + List<InstalledPlugin> plugins = Lists.newArrayList(wrapper); UberWebResourceLoader resourceLoader = new DefaultUberWebResourceLoader(servletContext, plugins); @@ -197,11 +197,11 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase WebResourceLoader loader = mock(WebResourceLoader.class); when(loader.getResource("/myresource")).thenReturn(url); - PluginWrapper pluginWrapper = mock(PluginWrapper.class); - when(pluginWrapper.getWebResourceLoader()).thenReturn(loader); + InstalledPlugin installedPlugin = mock(InstalledPlugin.class); + when(installedPlugin.getWebResourceLoader()).thenReturn(loader); WebResourceLoader resourceLoader = - new DefaultUberWebResourceLoader(servletContext, Lists.newArrayList(pluginWrapper)); + new DefaultUberWebResourceLoader(servletContext, Lists.newArrayList(installedPlugin)); assertNull(resourceLoader.getResource("/myresource")); } @@ -214,11 +214,11 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase WebResourceLoader loader = mock(WebResourceLoader.class); when(loader.getResource("/myresource")).thenReturn(url); - PluginWrapper pluginWrapper = mock(PluginWrapper.class); - when(pluginWrapper.getWebResourceLoader()).thenReturn(loader); + InstalledPlugin installedPlugin = mock(InstalledPlugin.class); + when(installedPlugin.getWebResourceLoader()).thenReturn(loader); UberWebResourceLoader resourceLoader = - new DefaultUberWebResourceLoader(servletContext, Lists.newArrayList(pluginWrapper)); + new DefaultUberWebResourceLoader(servletContext, Lists.newArrayList(installedPlugin)); List<URL> resources = resourceLoader.getResources("/myresource"); Assertions.assertThat(resources).isEmpty(); @@ -232,7 +232,7 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase * * @return */ - private PluginWrapper createPluginWrapper(File directory) + private InstalledPlugin createPluginWrapper(File directory) { return createPluginWrapper(directory.toPath()); } @@ -245,9 +245,9 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase * * @return */ - private PluginWrapper createPluginWrapper(Path directory) + private InstalledPlugin createPluginWrapper(Path directory) { - return new PluginWrapper(null, null, new PathWebResourceLoader(directory), + return new InstalledPlugin(null, null, new PathWebResourceLoader(directory), directory); } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java index 87e9cbf7b7..0fbf7eabbc 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java @@ -129,7 +129,7 @@ public class PluginProcessorTest { copySmp(PLUGIN_A); - PluginWrapper plugin = collectAndGetFirst(); + InstalledPlugin plugin = collectAndGetFirst(); assertThat(plugin.getId(), is(PLUGIN_A.id)); } @@ -145,15 +145,15 @@ public class PluginProcessorTest { copySmps(PLUGIN_A, PLUGIN_B); - Set<PluginWrapper> plugins = collectPlugins(); + Set<InstalledPlugin> plugins = collectPlugins(); assertThat(plugins, hasSize(2)); - PluginWrapper a = findPlugin(plugins, PLUGIN_A.id); + InstalledPlugin a = findPlugin(plugins, PLUGIN_A.id); assertNotNull(a); - PluginWrapper b = findPlugin(plugins, PLUGIN_B.id); + InstalledPlugin b = findPlugin(plugins, PLUGIN_B.id); assertNotNull(b); } @@ -178,7 +178,7 @@ public class PluginProcessorTest { copySmp(PLUGIN_A); - PluginWrapper plugin = collectAndGetFirst(); + InstalledPlugin plugin = collectAndGetFirst(); ClassLoader cl = plugin.getClassLoader(); // load parent class @@ -216,9 +216,9 @@ public class PluginProcessorTest { copySmps(PLUGIN_A, PLUGIN_B); - Set<PluginWrapper> plugins = collectPlugins(); + Set<InstalledPlugin> plugins = collectPlugins(); - PluginWrapper plugin = findPlugin(plugins, PLUGIN_B.id); + InstalledPlugin plugin = findPlugin(plugins, PLUGIN_B.id); ClassLoader cl = plugin.getClassLoader(); // load parent class @@ -247,7 +247,7 @@ public class PluginProcessorTest { copySmp(PLUGIN_A); - PluginWrapper plugin = collectAndGetFirst(); + InstalledPlugin plugin = collectAndGetFirst(); WebResourceLoader wrl = plugin.getWebResourceLoader(); assertNotNull(wrl); @@ -269,7 +269,7 @@ public class PluginProcessorTest { copySmp(PLUGIN_F_1_0_0); - PluginWrapper plugin = collectAndGetFirst(); + InstalledPlugin plugin = collectAndGetFirst(); assertThat(plugin.getId(), is(PLUGIN_F_1_0_0.id)); copySmp(PLUGIN_F_1_0_1); @@ -302,9 +302,9 @@ public class PluginProcessorTest * * @throws IOException */ - private PluginWrapper collectAndGetFirst() throws IOException + private InstalledPlugin collectAndGetFirst() throws IOException { - Set<PluginWrapper> plugins = collectPlugins(); + Set<InstalledPlugin> plugins = collectPlugins(); assertThat(plugins, hasSize(1)); @@ -319,7 +319,7 @@ public class PluginProcessorTest * * @throws IOException */ - private Set<PluginWrapper> collectPlugins() throws IOException + private Set<InstalledPlugin> collectPlugins() throws IOException { return processor.collectPlugins(PluginProcessorTest.class.getClassLoader()); } @@ -368,14 +368,14 @@ public class PluginProcessorTest * * @return */ - private PluginWrapper findPlugin(Iterable<PluginWrapper> plugin, - final String id) + private InstalledPlugin findPlugin(Iterable<InstalledPlugin> plugin, + final String id) { - return Iterables.find(plugin, new Predicate<PluginWrapper>() + return Iterables.find(plugin, new Predicate<InstalledPlugin>() { @Override - public boolean apply(PluginWrapper input) + public boolean apply(InstalledPlugin input) { return id.equals(input.getId()); } From 1a01216f62416f0f8ae198f222b89fbcd2224a04 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 08:05:41 +0200 Subject: [PATCH 093/135] renamed Plugin to InstalledPluginDescriptor and added PluginDescriptor interface --- .../java/sonia/scm/plugin/InstalledPlugin.java | 8 ++++---- ...lugin.java => InstalledPluginDescriptor.java} | 15 +++++++++------ .../java/sonia/scm/plugin/PluginDescriptor.java | 13 +++++++++++++ .../src/main/java/sonia/scm/plugin/Plugins.java | 10 +++++----- .../main/java/sonia/scm/plugin/SmpArchive.java | 8 ++++---- .../java/sonia/scm/plugin/SmpArchiveTest.java | 2 +- .../v2/resources/AvailablePluginResource.java | 4 ++-- .../v2/resources/InstalledPluginResource.java | 4 ++-- .../sonia/scm/lifecycle/PluginBootstrap.java | 4 ++-- .../sonia/scm/plugin/DefaultPluginLoader.java | 4 ++-- .../sonia/scm/plugin/DefaultPluginManager.java | 16 ++++++++-------- .../main/java/sonia/scm/plugin/ExplodedSmp.java | 6 +++--- .../java/sonia/scm/plugin/PluginProcessor.java | 10 +++++----- .../main/java/sonia/scm/plugin/PluginTree.java | 2 +- .../java/sonia/scm/plugin/PluginsInternal.java | 8 ++++---- .../resources/InstalledPluginResourceTest.java | 4 ++-- .../scm/api/v2/resources/UIRootResourceTest.java | 2 +- .../java/sonia/scm/plugin/ExplodedSmpTest.java | 2 +- .../java/sonia/scm/plugin/PluginTreeTest.java | 10 +++++----- 19 files changed, 74 insertions(+), 58 deletions(-) rename scm-core/src/main/java/sonia/scm/plugin/{Plugin.java => InstalledPluginDescriptor.java} (92%) create mode 100644 scm-core/src/main/java/sonia/scm/plugin/PluginDescriptor.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java index 8e93953074..19f9584408 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -36,7 +36,7 @@ package sonia.scm.plugin; import java.nio.file.Path; /** - * Wrapper for a {@link Plugin}. The wrapper holds the directory, + * Wrapper for a {@link InstalledPluginDescriptor}. The wrapper holds the directory, * {@link ClassLoader} and {@link WebResourceLoader} of a plugin. * * @author Sebastian Sdorra @@ -53,7 +53,7 @@ public final class InstalledPlugin * @param webResourceLoader web resource loader * @param directory plugin directory */ - public InstalledPlugin(Plugin plugin, ClassLoader classLoader, + public InstalledPlugin(InstalledPluginDescriptor plugin, ClassLoader classLoader, WebResourceLoader webResourceLoader, Path directory) { this.plugin = plugin; @@ -103,7 +103,7 @@ public final class InstalledPlugin * * @return plugin */ - public Plugin getPlugin() + public InstalledPluginDescriptor getPlugin() { return plugin; } @@ -128,7 +128,7 @@ public final class InstalledPlugin private final Path directory; /** plugin */ - private final Plugin plugin; + private final InstalledPluginDescriptor plugin; /** plugin web resource loader */ private final WebResourceLoader webResourceLoader; diff --git a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java similarity index 92% rename from scm-core/src/main/java/sonia/scm/plugin/Plugin.java rename to scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java index e8fd166e78..28504650bd 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java @@ -54,14 +54,14 @@ import java.util.Set; */ @XmlRootElement @XmlAccessorType(XmlAccessType.FIELD) -public final class Plugin extends ScmModule +public final class InstalledPluginDescriptor extends ScmModule implements PluginDescriptor { /** * Constructs ... * */ - Plugin() {} + InstalledPluginDescriptor() {} /** * Constructs ... @@ -74,9 +74,9 @@ public final class Plugin extends ScmModule * @param childFirstClassLoader * @param dependencies */ - public Plugin(int scmVersion, PluginInformation information, - PluginResources resources, PluginCondition condition, - boolean childFirstClassLoader, Set<String> dependencies) + public InstalledPluginDescriptor(int scmVersion, PluginInformation information, + PluginResources resources, PluginCondition condition, + boolean childFirstClassLoader, Set<String> dependencies) { this.scmVersion = scmVersion; this.information = information; @@ -109,7 +109,7 @@ public final class Plugin extends ScmModule return false; } - final Plugin other = (Plugin) obj; + final InstalledPluginDescriptor other = (InstalledPluginDescriptor) obj; return Objects.equal(scmVersion, other.scmVersion) && Objects.equal(condition, other.condition) @@ -161,6 +161,7 @@ public final class Plugin extends ScmModule * * @return */ + @Override public PluginCondition getCondition() { return condition; @@ -174,6 +175,7 @@ public final class Plugin extends ScmModule * * @since 2.0.0 */ + @Override public Set<String> getDependencies() { if (dependencies == null) @@ -190,6 +192,7 @@ public final class Plugin extends ScmModule * * @return */ + @Override public PluginInformation getInformation() { return information; diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginDescriptor.java b/scm-core/src/main/java/sonia/scm/plugin/PluginDescriptor.java new file mode 100644 index 0000000000..6e800faff0 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginDescriptor.java @@ -0,0 +1,13 @@ +package sonia.scm.plugin; + +import java.util.Set; + +public interface PluginDescriptor { + + PluginInformation getInformation(); + + PluginCondition getCondition(); + + Set<String> getDependencies(); + +} diff --git a/scm-core/src/main/java/sonia/scm/plugin/Plugins.java b/scm-core/src/main/java/sonia/scm/plugin/Plugins.java index 6359850712..f33a254581 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/Plugins.java +++ b/scm-core/src/main/java/sonia/scm/plugin/Plugins.java @@ -65,7 +65,7 @@ public final class Plugins { try { - context = JAXBContext.newInstance(Plugin.class, ScmModule.class); + context = JAXBContext.newInstance(InstalledPluginDescriptor.class, ScmModule.class); } catch (JAXBException ex) { @@ -91,7 +91,7 @@ public final class Plugins * * @return */ - public static Plugin parsePluginDescriptor(Path path) + public static InstalledPluginDescriptor parsePluginDescriptor(Path path) { return parsePluginDescriptor(Files.asByteSource(path.toFile())); } @@ -104,15 +104,15 @@ public final class Plugins * * @return */ - public static Plugin parsePluginDescriptor(ByteSource data) + public static InstalledPluginDescriptor parsePluginDescriptor(ByteSource data) { Preconditions.checkNotNull(data, "data parameter is required"); - Plugin plugin; + InstalledPluginDescriptor plugin; try (InputStream stream = data.openStream()) { - plugin = (Plugin) context.createUnmarshaller().unmarshal(stream); + plugin = (InstalledPluginDescriptor) context.createUnmarshaller().unmarshal(stream); } catch (JAXBException ex) { diff --git a/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java b/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java index f674bdd2ba..e1ea622bdf 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java +++ b/scm-core/src/main/java/sonia/scm/plugin/SmpArchive.java @@ -206,7 +206,7 @@ public final class SmpArchive * * @throws IOException */ - public Plugin getPlugin() throws IOException + public InstalledPluginDescriptor getPlugin() throws IOException { if (plugin == null) { @@ -245,9 +245,9 @@ public final class SmpArchive * * @throws IOException */ - private Plugin createPlugin() throws IOException + private InstalledPluginDescriptor createPlugin() throws IOException { - Plugin p = null; + InstalledPluginDescriptor p = null; NonClosingZipInputStream zis = null; try @@ -412,5 +412,5 @@ public final class SmpArchive private final ByteSource archive; /** Field description */ - private Plugin plugin; + private InstalledPluginDescriptor plugin; } diff --git a/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java b/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java index d7f4ecf515..07e182216f 100644 --- a/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java +++ b/scm-core/src/test/java/sonia/scm/plugin/SmpArchiveTest.java @@ -113,7 +113,7 @@ public class SmpArchiveTest public void testGetPlugin() throws IOException { File archive = createArchive("sonia.sample", "1.0"); - Plugin plugin = SmpArchive.create(archive).getPlugin(); + InstalledPluginDescriptor plugin = SmpArchive.create(archive).getPlugin(); assertNotNull(plugin); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 6d5711133f..7ab49306fd 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -3,7 +3,7 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; -import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; @@ -83,7 +83,7 @@ public class AvailablePluginResource { if (plugin.isPresent()) { return Response.ok(mapper.map(plugin.get())).build(); } else { - throw notFound(entity(Plugin.class, name)); + throw notFound(entity(InstalledPluginDescriptor.class, name)); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index 230b34171b..e18f6772dc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -3,7 +3,7 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; -import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; @@ -83,7 +83,7 @@ public class InstalledPluginResource { if (pluginDto.isPresent()) { return Response.ok(pluginDto.get()).build(); } else { - throw notFound(entity(Plugin.class, name)); + throw notFound(entity(InstalledPluginDescriptor.class, name)); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java index 3d7e6ec0b2..ff8c28f51d 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java @@ -9,7 +9,7 @@ import sonia.scm.SCMContext; import sonia.scm.lifecycle.classloading.ClassLoaderLifeCycle; import sonia.scm.migration.UpdateException; import sonia.scm.plugin.DefaultPluginLoader; -import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginException; import sonia.scm.plugin.PluginLoadException; import sonia.scm.plugin.PluginLoader; @@ -105,7 +105,7 @@ public final class PluginBootstrap { PluginIndexEntry entry) throws IOException { URL url = context.getResource(PLUGIN_DIRECTORY.concat(entry.getName())); SmpArchive archive = SmpArchive.create(url); - Plugin plugin = archive.getPlugin(); + InstalledPluginDescriptor plugin = archive.getPlugin(); File directory = PluginsInternal.createPluginDirectory(pluginDirectory, plugin); File checksumFile = PluginsInternal.getChecksumFile(directory); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java index 1e4a4b92d7..5612b0395c 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginLoader.java @@ -95,7 +95,7 @@ public class DefaultPluginLoader implements PluginLoader try { JAXBContext context = JAXBContext.newInstance(ScmModule.class, - Plugin.class); + InstalledPluginDescriptor.class); modules = getInstalled(parent, context, PATH_MODULECONFIG); @@ -178,7 +178,7 @@ public class DefaultPluginLoader implements PluginLoader * * @return */ - private Iterable<Plugin> unwrap() + private Iterable<InstalledPluginDescriptor> unwrap() { return PluginsInternal.unwrap(installedPlugins); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 70cbab23ff..509870abf8 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -131,7 +131,7 @@ public class DefaultPluginManager implements PluginManager for (InstalledPlugin wrapper : pluginLoader.getInstalledPlugins()) { - Plugin plugin = wrapper.getPlugin(); + InstalledPluginDescriptor plugin = wrapper.getPlugin(); PluginInformation info = plugin.getInformation(); if ((info != null) && info.isValid()) @@ -192,7 +192,7 @@ public class DefaultPluginManager implements PluginManager plugin.setState(PluginState.INSTALLED); // ugly workaround - Plugin newPlugin = new Plugin(); + InstalledPluginDescriptor newPlugin = new InstalledPluginDescriptor(); // TODO check // newPlugin.setInformation(plugin); @@ -220,8 +220,8 @@ public class DefaultPluginManager implements PluginManager { new ZipUnArchiver().extractArchive(packageStream, tempDirectory); - Plugin plugin = JAXB.unmarshal(new File(tempDirectory, "plugin.xml"), - Plugin.class); + InstalledPluginDescriptor plugin = JAXB.unmarshal(new File(tempDirectory, "plugin.xml"), + InstalledPluginDescriptor.class); PluginCondition condition = plugin.getCondition(); @@ -262,7 +262,7 @@ public class DefaultPluginManager implements PluginManager { PluginPermissions.manage().check(); - Plugin plugin = installedPlugins.get(id); + InstalledPluginDescriptor plugin = installedPlugins.get(id); if (plugin == null) { @@ -457,7 +457,7 @@ public class DefaultPluginManager implements PluginManager Set<PluginInformation> infoSet = new LinkedHashSet<>(); - for (Plugin plugin : installedPlugins.values()) + for (InstalledPluginDescriptor plugin : installedPlugins.values()) { infoSet.add(plugin.getInformation()); } @@ -647,7 +647,7 @@ public class DefaultPluginManager implements PluginManager { boolean core = false; - for (Plugin installedPlugin : installedPlugins.values()) + for (InstalledPluginDescriptor installedPlugin : installedPlugins.values()) { PluginInformation installed = installedPlugin.getInformation(); @@ -715,5 +715,5 @@ public class DefaultPluginManager implements PluginManager private final SCMContextProvider context; /** Field description */ - private final Map<String, Plugin> installedPlugins; + private final Map<String, InstalledPluginDescriptor> installedPlugins; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java index 372470df14..ee2514dc3e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java @@ -63,7 +63,7 @@ public final class ExplodedSmp implements Comparable<ExplodedSmp> * @param path * @param plugin */ - ExplodedSmp(Path path, Plugin plugin) + ExplodedSmp(Path path, InstalledPluginDescriptor plugin) { logger.trace("create exploded scm for plugin {} and dependencies {}", plugin.getInformation().getName(), plugin.getDependencies()); this.path = path; @@ -163,7 +163,7 @@ public final class ExplodedSmp implements Comparable<ExplodedSmp> * * @return plugin descriptor */ - public Plugin getPlugin() + public InstalledPluginDescriptor getPlugin() { return plugin; } @@ -202,5 +202,5 @@ public final class ExplodedSmp implements Comparable<ExplodedSmp> private final Path path; /** plugin object */ - private final Plugin plugin; + private final InstalledPluginDescriptor plugin; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 3a168f43d2..ed1bc7643a 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -123,7 +123,7 @@ public final class PluginProcessor try { - this.context = JAXBContext.newInstance(Plugin.class); + this.context = JAXBContext.newInstance(InstalledPluginDescriptor.class); } catch (JAXBException ex) { @@ -371,7 +371,7 @@ public final class PluginProcessor ClassLoader classLoader; URL[] urlArray = urls.toArray(new URL[urls.size()]); - Plugin plugin = smp.getPlugin(); + InstalledPluginDescriptor plugin = smp.getPlugin(); String id = plugin.getInformation().getName(false); @@ -441,7 +441,7 @@ public final class PluginProcessor * * @return */ - private Plugin createPlugin(ClassLoader classLoader, Path descriptor) + private InstalledPluginDescriptor createPlugin(ClassLoader classLoader, Path descriptor) { ClassLoader ctxcl = Thread.currentThread().getContextClassLoader(); @@ -449,7 +449,7 @@ public final class PluginProcessor try { - return (Plugin) context.createUnmarshaller().unmarshal( + return (InstalledPluginDescriptor) context.createUnmarshaller().unmarshal( descriptor.toFile()); } catch (JAXBException ex) @@ -486,7 +486,7 @@ public final class PluginProcessor { ClassLoader cl = createClassLoader(classLoader, smp); - Plugin plugin = createPlugin(cl, descriptor); + InstalledPluginDescriptor plugin = createPlugin(cl, descriptor); WebResourceLoader resourceLoader = createWebResourceLoader(directory); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java index 7e57fb3d57..bd338c0741 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java @@ -86,7 +86,7 @@ public final class PluginTree for (ExplodedSmp smp : smpOrdered) { - Plugin plugin = smp.getPlugin(); + InstalledPluginDescriptor plugin = smp.getPlugin(); if (plugin.getScmVersion() != SCM_VERSION) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java index fb916ee5ec..9ac8bcbe71 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java @@ -105,7 +105,7 @@ public final class PluginsInternal * * @return */ - public static File createPluginDirectory(File parent, Plugin plugin) + public static File createPluginDirectory(File parent, InstalledPluginDescriptor plugin) { PluginInformation info = plugin.getInformation(); @@ -159,7 +159,7 @@ public final class PluginsInternal * * @return */ - public static Iterable<Plugin> unwrap(Iterable<InstalledPlugin> wrapped) + public static Iterable<InstalledPluginDescriptor> unwrap(Iterable<InstalledPlugin> wrapped) { return Iterables.transform(wrapped, new Unwrap()); } @@ -188,7 +188,7 @@ public final class PluginsInternal * @version Enter version here..., 14/06/05 * @author Enter your name here... */ - private static class Unwrap implements Function<InstalledPlugin, Plugin> + private static class Unwrap implements Function<InstalledPlugin, InstalledPluginDescriptor> { /** @@ -200,7 +200,7 @@ public final class PluginsInternal * @return */ @Override - public Plugin apply(InstalledPlugin wrapper) + public InstalledPluginDescriptor apply(InstalledPlugin wrapper) { return wrapper.getPlugin(); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index 098a12880a..ce781c7c32 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginState; @@ -106,7 +106,7 @@ class InstalledPluginResourceTest { pluginInformation.setVersion("2.0.0"); pluginInformation.setName("pluginName"); pluginInformation.setState(PluginState.INSTALLED); - Plugin plugin = new Plugin(2, pluginInformation, null, null, false, null); + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, pluginInformation, null, null, false, null); InstalledPlugin installedPlugin = new InstalledPlugin(plugin, null, null, null); when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(installedPlugin)); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java index 2dc195808e..7fd605f24c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java @@ -188,7 +188,7 @@ public class UIRootResourceTest { InstalledPlugin wrapper = mock(InstalledPlugin.class); when(wrapper.getId()).thenReturn(id); - Plugin plugin = mock(Plugin.class); + InstalledPluginDescriptor plugin = mock(InstalledPluginDescriptor.class); when(wrapper.getPlugin()).thenReturn(plugin); when(plugin.getResources()).thenReturn(pluginResources); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java index 601725d938..090d903f49 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/ExplodedSmpTest.java @@ -133,7 +133,7 @@ public class ExplodedSmpTest info.setName(name); info.setVersion(version); - Plugin plugin = new Plugin(2, info, null, null, false, + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, info, null, null, false, Sets.newSet(dependencies)); return new ExplodedSmp(null, plugin); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java index 0115f4510e..72c48d4d16 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java @@ -71,7 +71,7 @@ public class PluginTreeTest { PluginCondition condition = new PluginCondition("999", new ArrayList<String>(), "hit"); - Plugin plugin = new Plugin(2, createInfo("a", "1"), null, condition, + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition, false, null); ExplodedSmp smp = createSmp(plugin); @@ -114,7 +114,7 @@ public class PluginTreeTest @Test(expected = PluginException.class) public void testScmVersion() throws IOException { - Plugin plugin = new Plugin(1, createInfo("a", "1"), null, null, false, + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(1, createInfo("a", "1"), null, null, false, null); ExplodedSmp smp = createSmp(plugin); @@ -182,7 +182,7 @@ public class PluginTreeTest * * @throws IOException */ - private ExplodedSmp createSmp(Plugin plugin) throws IOException + private ExplodedSmp createSmp(InstalledPluginDescriptor plugin) throws IOException { return new ExplodedSmp(tempFolder.newFile().toPath(), plugin); } @@ -199,7 +199,7 @@ public class PluginTreeTest */ private ExplodedSmp createSmp(String name) throws IOException { - return createSmp(new Plugin(2, createInfo(name, "1.0.0"), null, null, + return createSmp(new InstalledPluginDescriptor(2, createInfo(name, "1.0.0"), null, null, false, null)); } @@ -225,7 +225,7 @@ public class PluginTreeTest dependencySet.add(d); } - Plugin plugin = new Plugin(2, createInfo(name, "1"), null, null, + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo(name, "1"), null, null, false, dependencySet); return createSmp(plugin); From 0aaec1174a4fb28c69e1e7358acee0acfc4ece34 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 08:10:30 +0200 Subject: [PATCH 094/135] introduce Plugin interface --- .../sonia/scm/plugin/InstalledPlugin.java | 26 ++++++++++++------- .../main/java/sonia/scm/plugin/Plugin.java | 8 ++++++ .../v2/resources/InstalledPluginResource.java | 2 +- .../scm/api/v2/resources/PluginDtoMapper.java | 2 +- .../api/v2/resources/UIPluginDtoMapper.java | 4 +-- .../api/v2/resources/UIPluginResource.java | 2 +- .../scm/plugin/DefaultPluginManager.java | 2 +- .../sonia/scm/plugin/PluginsInternal.java | 2 +- .../api/v2/resources/UIRootResourceTest.java | 2 +- 9 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/plugin/Plugin.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java index 19f9584408..fc1fbac94a 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -42,21 +42,21 @@ import java.nio.file.Path; * @author Sebastian Sdorra * @since 2.0.0 */ -public final class InstalledPlugin +public final class InstalledPlugin implements Plugin { /** * Constructs a new plugin wrapper. * - * @param plugin wrapped plugin + * @param descriptor wrapped plugin * @param classLoader plugin class loader * @param webResourceLoader web resource loader * @param directory plugin directory */ - public InstalledPlugin(InstalledPluginDescriptor plugin, ClassLoader classLoader, + public InstalledPlugin(InstalledPluginDescriptor descriptor, ClassLoader classLoader, WebResourceLoader webResourceLoader, Path directory) { - this.plugin = plugin; + this.descriptor = descriptor; this.classLoader = classLoader; this.webResourceLoader = webResourceLoader; this.directory = directory; @@ -94,18 +94,19 @@ public final class InstalledPlugin */ public String getId() { - return plugin.getInformation().getId(); + return descriptor.getInformation().getId(); } /** - * Returns the plugin. + * Returns the plugin descriptor. * * - * @return plugin + * @return plugin descriptor */ - public InstalledPluginDescriptor getPlugin() + @Override + public InstalledPluginDescriptor getDescriptor() { - return plugin; + return descriptor; } /** @@ -119,6 +120,11 @@ public final class InstalledPlugin return webResourceLoader; } + @Override + public PluginState getState() { + return PluginState.INSTALLED; + } + //~--- fields --------------------------------------------------------------- /** plugin class loader */ @@ -128,7 +134,7 @@ public final class InstalledPlugin private final Path directory; /** plugin */ - private final InstalledPluginDescriptor plugin; + private final InstalledPluginDescriptor descriptor; /** plugin web resource loader */ private final WebResourceLoader webResourceLoader; diff --git a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java b/scm-core/src/main/java/sonia/scm/plugin/Plugin.java new file mode 100644 index 0000000000..e39d23c046 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/plugin/Plugin.java @@ -0,0 +1,8 @@ +package sonia.scm.plugin; + +public interface Plugin { + + PluginDescriptor getDescriptor(); + PluginState getState(); + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index e18f6772dc..66347814a3 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -77,7 +77,7 @@ public class InstalledPluginResource { PluginPermissions.read().check(); Optional<PluginDto> pluginDto = pluginLoader.getInstalledPlugins() .stream() - .filter(plugin -> name.equals(plugin.getPlugin().getInformation().getName())) + .filter(plugin -> name.equals(plugin.getDescriptor().getInformation().getName())) .map(mapper::map) .findFirst(); if (pluginDto.isPresent()) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 4967c55b31..4710fa943c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -21,7 +21,7 @@ public abstract class PluginDtoMapper { private ResourceLinks resourceLinks; public PluginDto map(InstalledPlugin plugin) { - return map(plugin.getPlugin().getInformation()); + return map(plugin.getDescriptor().getInformation()); } public abstract PluginDto map(PluginInformation plugin); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java index 8a2b6cb0c1..5eecaa0561 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginDtoMapper.java @@ -27,7 +27,7 @@ public class UIPluginDtoMapper { public UIPluginDto map(InstalledPlugin plugin) { UIPluginDto dto = new UIPluginDto( - plugin.getPlugin().getInformation().getName(), + plugin.getDescriptor().getInformation().getName(), getScriptResources(plugin) ); @@ -41,7 +41,7 @@ public class UIPluginDtoMapper { } private Set<String> getScriptResources(InstalledPlugin wrapper) { - Set<String> scriptResources = wrapper.getPlugin().getResources().getScriptResources(); + Set<String> scriptResources = wrapper.getDescriptor().getResources().getScriptResources(); if (scriptResources != null) { return scriptResources.stream() .map(this::addContextPath) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java index d34bcbe3ba..1c779653a0 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UIPluginResource.java @@ -86,7 +86,7 @@ public class UIPluginResource { } private boolean filter(InstalledPlugin plugin) { - return plugin.getPlugin().getResources() != null; + return plugin.getDescriptor().getResources() != null; } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 509870abf8..c19c19b377 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -131,7 +131,7 @@ public class DefaultPluginManager implements PluginManager for (InstalledPlugin wrapper : pluginLoader.getInstalledPlugins()) { - InstalledPluginDescriptor plugin = wrapper.getPlugin(); + InstalledPluginDescriptor plugin = wrapper.getDescriptor(); PluginInformation info = plugin.getInformation(); if ((info != null) && info.isValid()) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java index 9ac8bcbe71..242086aa85 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginsInternal.java @@ -202,7 +202,7 @@ public final class PluginsInternal @Override public InstalledPluginDescriptor apply(InstalledPlugin wrapper) { - return wrapper.getPlugin(); + return wrapper.getDescriptor(); } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java index 7fd605f24c..4987dec644 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java @@ -189,7 +189,7 @@ public class UIRootResourceTest { when(wrapper.getId()).thenReturn(id); InstalledPluginDescriptor plugin = mock(InstalledPluginDescriptor.class); - when(wrapper.getPlugin()).thenReturn(plugin); + when(wrapper.getDescriptor()).thenReturn(plugin); when(plugin.getResources()).thenReturn(pluginResources); PluginInformation information = mock(PluginInformation.class); From 3f1521bccaf3c369c0509269cc01030155780966 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 10:33:57 +0200 Subject: [PATCH 095/135] create new simplified PluginManager API --- .../sonia/scm/plugin/AvailablePlugin.java | 20 + .../scm/plugin/AvailablePluginDescriptor.java | 34 + .../scm/plugin/InstalledPluginDescriptor.java | 3 +- .../java/sonia/scm/plugin/PluginCenter.java | 120 ---- .../sonia/scm/plugin/PluginInformation.java | 2 - .../plugin/PluginInformationComparator.java | 101 --- .../java/sonia/scm/plugin/PluginManager.java | 109 +-- .../sonia/scm/plugin/PluginRepository.java | 160 ----- .../scm/plugin/StatePluginPredicate.java | 78 -- .../v2/resources/AvailablePluginResource.java | 29 +- .../v2/resources/InstalledPluginResource.java | 22 +- .../resources/PluginDtoCollectionMapper.java | 5 +- .../scm/api/v2/resources/PluginDtoMapper.java | 24 +- .../scm/plugin/DefaultPluginManager.java | 668 +----------------- .../scm/plugin/OverviewPluginPredicate.java | 64 -- .../sonia/scm/plugin/PluginProcessor.java | 67 +- .../AvailablePluginResourceTest.java | 39 +- .../InstalledPluginResourceTest.java | 40 +- .../api/v2/resources/PluginDtoMapperTest.java | 45 +- 19 files changed, 220 insertions(+), 1410 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java create mode 100644 scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java delete mode 100644 scm-core/src/main/java/sonia/scm/plugin/PluginCenter.java delete mode 100644 scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java delete mode 100644 scm-core/src/main/java/sonia/scm/plugin/PluginRepository.java delete mode 100644 scm-core/src/main/java/sonia/scm/plugin/StatePluginPredicate.java delete mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/OverviewPluginPredicate.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java b/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java new file mode 100644 index 0000000000..6596fa4751 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java @@ -0,0 +1,20 @@ +package sonia.scm.plugin; + +public class AvailablePlugin implements Plugin { + + private final AvailablePluginDescriptor pluginDescriptor; + + public AvailablePlugin(AvailablePluginDescriptor pluginDescriptor) { + this.pluginDescriptor = pluginDescriptor; + } + + @Override + public AvailablePluginDescriptor getDescriptor() { + return pluginDescriptor; + } + + @Override + public PluginState getState() { + return PluginState.AVAILABLE; + } +} diff --git a/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java b/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java new file mode 100644 index 0000000000..b7e7b5e282 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java @@ -0,0 +1,34 @@ +package sonia.scm.plugin; + +import java.util.Set; + +/** + * @since 2.0.0 + */ +public class AvailablePluginDescriptor implements PluginDescriptor { + + private final PluginInformation information; + private final PluginCondition condition; + private final Set<String> dependencies; + + public AvailablePluginDescriptor(PluginInformation information, PluginCondition condition, Set<String> dependencies) { + this.information = information; + this.condition = condition; + this.dependencies = dependencies; + } + + @Override + public PluginInformation getInformation() { + return information; + } + + @Override + public PluginCondition getCondition() { + return condition; + } + + @Override + public Set<String> getDependencies() { + return dependencies; + } +} diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java index 28504650bd..88ae4c5dff 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java @@ -52,7 +52,7 @@ import java.util.Set; * * @author Sebastian Sdorra */ -@XmlRootElement +@XmlRootElement(name = "plugin") @XmlAccessorType(XmlAccessType.FIELD) public final class InstalledPluginDescriptor extends ScmModule implements PluginDescriptor { @@ -247,6 +247,7 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin private Set<String> dependencies; /** Field description */ + @XmlElement(name = "information") private PluginInformation information; /** Field description */ diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginCenter.java b/scm-core/src/main/java/sonia/scm/plugin/PluginCenter.java deleted file mode 100644 index e1598e0490..0000000000 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginCenter.java +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.plugin; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.Serializable; - -import java.util.HashSet; -import java.util.Set; - -import javax.xml.bind.annotation.XmlAccessType; -import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlElementWrapper; -import javax.xml.bind.annotation.XmlRootElement; - -/** - * - * @author Sebastian Sdorra - */ -@XmlRootElement(name = "plugin-center") -@XmlAccessorType(XmlAccessType.FIELD) -public class PluginCenter implements Serializable -{ - - /** Field description */ - private static final long serialVersionUID = -6414175308610267397L; - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public Set<PluginInformation> getPlugins() - { - return plugins; - } - - /** - * Method description - * - * - * @return - */ - public Set<PluginRepository> getRepositories() - { - return repositories; - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param plugins - */ - public void setPlugins(Set<PluginInformation> plugins) - { - this.plugins = plugins; - } - - /** - * Method description - * - * - * @param repositories - */ - public void setRepositories(Set<PluginRepository> repositories) - { - this.repositories = repositories; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - @XmlElement(name = "plugin") - @XmlElementWrapper(name = "plugins") - private Set<PluginInformation> plugins = new HashSet<PluginInformation>(); - - /** Field description */ - @XmlElement(name = "repository") - @XmlElementWrapper(name = "repositories") - private Set<PluginRepository> repositories = new HashSet<PluginRepository>(); -} diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 22911041d4..a9ae3b07f6 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -71,7 +71,6 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea private String category; private String avatarUrl; private PluginCondition condition; - private PluginState state; @Override public PluginInformation clone() { @@ -83,7 +82,6 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea clone.setAuthor(author); clone.setCategory(category); clone.setAvatarUrl(avatarUrl); - clone.setState(state); if (condition != null) { clone.setCondition(condition.clone()); } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java deleted file mode 100644 index 5443b2328d..0000000000 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformationComparator.java +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.plugin; - -//~--- non-JDK imports -------------------------------------------------------- - -import sonia.scm.util.Util; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.Serializable; - -import java.util.Comparator; - -/** - * - * @author Sebastian Sdorra - * @since 1.6 - */ -public class PluginInformationComparator - implements Comparator<PluginInformation>, Serializable -{ - - /** Field description */ - public static final PluginInformationComparator INSTANCE = - new PluginInformationComparator(); - - /** Field description */ - private static final long serialVersionUID = -8339752498853225668L; - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param plugin - * @param other - * - * @return - */ - @Override - public int compare(PluginInformation plugin, PluginInformation other) - { - int result = 0; - - result = Util.compare(plugin.getName(), other.getName()); - - if (result == 0) - { - PluginState state = plugin.getState(); - PluginState otherState = other.getState(); - - if ((state != null) && (otherState != null)) - { - result = state.getCompareValue() - otherState.getCompareValue(); - } - else if ((state == null) && (otherState != null)) - { - result = 1; - } - else if ((state != null) && (otherState == null)) - { - result = -1; - } - } - - return result; - } -} diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index b1ec502fc4..ad9045544c 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -33,113 +33,50 @@ package sonia.scm.plugin; -//~--- JDK imports ------------------------------------------------------------ - -import com.google.common.base.Predicate; -import java.io.IOException; -import java.io.InputStream; - -import java.util.Collection; +import java.util.List; +import java.util.Optional; /** + * The plugin manager is responsible for plugin related tasks, such as install, uninstall or updating. * * @author Sebastian Sdorra */ -public interface PluginManager -{ +public interface PluginManager { /** - * Method description - * + * Returns the available plugin with the given name. + * @param name of plugin + * @return optional available plugin. */ - public void clearCache(); + Optional<AvailablePlugin> getAvailable(String name); /** - * Method description - * - * - * @param id + * Returns the installed plugin with the given name. + * @param name of plugin + * @return optional installed plugin. */ - public void install(String id); + Optional<InstalledPlugin> getInstalled(String name); + /** - * Installs a plugin package from a inputstream. + * Returns all installed plugins. * - * - * @param packageStream package input stream - * - * @throws IOException - * @since 1.21 + * @return a list of installed plugins. */ - public void installPackage(InputStream packageStream) throws IOException; + List<InstalledPlugin> getInstalled(); /** - * Method description + * Returns all available plugins. The list contains the plugins which are loaded from the plugin center, but without + * the installed plugins. * - * - * @param id + * @return a list of available plugins. */ - public void uninstall(String id); + List<AvailablePlugin> getAvailable(); /** - * Method description + * Installs the plugin with the given name from the list of available plugins. * - * - * @param id + * @param name plugin name */ - public void update(String id); - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param id - * - * @return - */ - public PluginInformation get(String id); - - /** - * Method description - * - * - * @param filter - * - * @return - */ - public Collection<PluginInformation> get(Predicate<PluginInformation> filter); - - /** - * Method description - * - * - * @return - */ - public Collection<PluginInformation> getAll(); - - /** - * Method description - * - * - * @return - */ - public Collection<PluginInformation> getAvailable(); - - /** - * Method description - * - * - * @return - */ - public Collection<PluginInformation> getAvailableUpdates(); - - /** - * Method description - * - * - * @return - */ - public Collection<PluginInformation> getInstalled(); + void install(String name); } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginRepository.java b/scm-core/src/main/java/sonia/scm/plugin/PluginRepository.java deleted file mode 100644 index 1d4cc07338..0000000000 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginRepository.java +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.plugin; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.MoreObjects; -import com.google.common.base.Objects; - -import java.io.Serializable; - -//~--- JDK imports ------------------------------------------------------------ - -/** - * - * @author Sebastian Sdorra - */ -public class PluginRepository implements Serializable -{ - - /** Field description */ - private static final long serialVersionUID = -9504354306304731L; - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - */ - PluginRepository() {} - - /** - * Constructs ... - * - * - * @param id - * @param url - */ - public PluginRepository(String id, String url) - { - this.id = id; - this.url = url; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param obj - * - * @return - */ - @Override - public boolean equals(Object obj) - { - if (obj == null) - { - return false; - } - - if (getClass() != obj.getClass()) - { - return false; - } - - final PluginRepository other = (PluginRepository) obj; - - return Objects.equal(id, other.id) && Objects.equal(url, other.url); - } - - /** - * Method description - * - * - * @return - */ - @Override - public int hashCode() - { - return Objects.hashCode(id, url); - } - - /** - * Method description - * - * - * @return - */ - @Override - public String toString() - { - return MoreObjects.toStringHelper(this).add("id", id).add("url", - url).toString(); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public String getId() - { - return id; - } - - /** - * Method description - * - * - * @return - */ - public String getUrl() - { - return url; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private String id; - - /** Field description */ - private String url; -} diff --git a/scm-core/src/main/java/sonia/scm/plugin/StatePluginPredicate.java b/scm-core/src/main/java/sonia/scm/plugin/StatePluginPredicate.java deleted file mode 100644 index ef7836f74a..0000000000 --- a/scm-core/src/main/java/sonia/scm/plugin/StatePluginPredicate.java +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.plugin; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Predicate; - -/** - * - * @author Sebastian Sdorra - */ -public class StatePluginPredicate implements Predicate<PluginInformation> -{ - - /** - * Constructs ... - * - * - * @param state - */ - public StatePluginPredicate(PluginState state) - { - this.state = state; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param plugin - * - * @return - */ - @Override - public boolean apply(PluginInformation plugin) - { - return state == plugin.getState(); - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final PluginState state; -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 7ab49306fd..6e90106096 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -3,11 +3,10 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.InstalledPluginDescriptor; -import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; -import sonia.scm.plugin.PluginState; import sonia.scm.web.VndMediaType; import javax.inject.Inject; @@ -18,9 +17,8 @@ import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; -import java.util.Collection; +import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -53,11 +51,8 @@ public class AvailablePluginResource { @Produces(VndMediaType.PLUGIN_COLLECTION) public Response getAvailablePlugins() { PluginPermissions.read().check(); - Collection<PluginInformation> plugins = pluginManager.getAvailable() - .stream() - .filter(plugin -> plugin.getState().equals(PluginState.AVAILABLE)) - .collect(Collectors.toList()); - return Response.ok(collectionMapper.map(plugins)).build(); + List<AvailablePlugin> available = pluginManager.getAvailable(); + return Response.ok(collectionMapper.mapAvailable(available)).build(); } /** @@ -66,7 +61,7 @@ public class AvailablePluginResource { * @return available plugin. */ @GET - @Path("/{name}/{version}") + @Path("/{name}") @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 404, condition = "not found"), @@ -74,12 +69,9 @@ public class AvailablePluginResource { }) @TypeHint(PluginDto.class) @Produces(VndMediaType.PLUGIN) - public Response getAvailablePlugin(@PathParam("name") String name, @PathParam("version") String version) { + public Response getAvailablePlugin(@PathParam("name") String name) { PluginPermissions.read().check(); - Optional<PluginInformation> plugin = pluginManager.getAvailable() - .stream() - .filter(p -> p.getId().equals(name + ":" + version)) - .findFirst(); + Optional<AvailablePlugin> plugin = pluginManager.getAvailable(name); if (plugin.isPresent()) { return Response.ok(mapper.map(plugin.get())).build(); } else { @@ -90,19 +82,18 @@ public class AvailablePluginResource { /** * Triggers plugin installation. * @param name plugin artefact name - * @param version plugin version * @return HTTP Status. */ @POST - @Path("/{name}/{version}/install") + @Path("/{name}/install") @Consumes(VndMediaType.PLUGIN) @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") }) - public Response installPlugin(@PathParam("name") String name, @PathParam("version") String version) { + public Response installPlugin(@PathParam("name") String name) { PluginPermissions.manage().check(); - pluginManager.install(name + ":" + version); + pluginManager.install(name); return Response.ok().build(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index 66347814a3..7c3f972a7b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -3,11 +3,10 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPluginDescriptor; -import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; -import sonia.scm.plugin.InstalledPlugin; import sonia.scm.web.VndMediaType; import javax.inject.Inject; @@ -16,7 +15,6 @@ import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; -import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -25,17 +23,15 @@ import static sonia.scm.NotFoundException.notFound; public class InstalledPluginResource { - private final PluginLoader pluginLoader; private final PluginDtoCollectionMapper collectionMapper; private final PluginDtoMapper mapper; private final PluginManager pluginManager; @Inject - public InstalledPluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper, PluginManager pluginManager) { - this.pluginLoader = pluginLoader; + public InstalledPluginResource(PluginManager pluginManager, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper) { + this.pluginManager = pluginManager; this.collectionMapper = collectionMapper; this.mapper = mapper; - this.pluginManager = pluginManager; } /** @@ -53,8 +49,8 @@ public class InstalledPluginResource { @Produces(VndMediaType.PLUGIN_COLLECTION) public Response getInstalledPlugins() { PluginPermissions.read().check(); - List<InstalledPlugin> plugins = new ArrayList<>(pluginLoader.getInstalledPlugins()); - return Response.ok(collectionMapper.map(plugins)).build(); + List<InstalledPlugin> plugins = pluginManager.getInstalled(); + return Response.ok(collectionMapper.mapInstalled(plugins)).build(); } /** @@ -75,13 +71,9 @@ public class InstalledPluginResource { @Produces(VndMediaType.PLUGIN) public Response getInstalledPlugin(@PathParam("name") String name) { PluginPermissions.read().check(); - Optional<PluginDto> pluginDto = pluginLoader.getInstalledPlugins() - .stream() - .filter(plugin -> name.equals(plugin.getDescriptor().getInformation().getName())) - .map(mapper::map) - .findFirst(); + Optional<InstalledPlugin> pluginDto = pluginManager.getInstalled(name); if (pluginDto.isPresent()) { - return Response.ok(pluginDto.get()).build(); + return Response.ok(mapper.map(pluginDto.get())).build(); } else { throw notFound(entity(InstalledPluginDescriptor.class, name)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index c835362df7..276eddfab6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -4,6 +4,7 @@ import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; +import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.InstalledPlugin; @@ -25,12 +26,12 @@ public class PluginDtoCollectionMapper { this.mapper = mapper; } - public HalRepresentation map(List<InstalledPlugin> plugins) { + public HalRepresentation mapInstalled(List<InstalledPlugin> plugins) { List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList()); return new HalRepresentation(createInstalledPluginsLinks(), embedDtos(dtos)); } - public HalRepresentation map(Collection<PluginInformation> plugins) { + public HalRepresentation mapAvailable(List<AvailablePlugin> plugins) { List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList()); return new HalRepresentation(createAvailablePluginsLinks(), embedDtos(dtos)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 4710fa943c..bef5a9b496 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -1,13 +1,11 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; -import org.mapstruct.ObjectFactory; +import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginState; -import sonia.scm.plugin.InstalledPlugin; import javax.inject.Inject; @@ -20,23 +18,23 @@ public abstract class PluginDtoMapper { @Inject private ResourceLinks resourceLinks; - public PluginDto map(InstalledPlugin plugin) { - return map(plugin.getDescriptor().getInformation()); - } + public abstract void map(PluginInformation plugin, @MappingTarget PluginDto dto); - public abstract PluginDto map(PluginInformation plugin); - - @AfterMapping - protected void appendCategory(@MappingTarget PluginDto dto) { + public PluginDto map(Plugin plugin) { + PluginDto dto = createDto(plugin); + map(plugin.getDescriptor().getInformation(), dto); if (dto.getCategory() == null) { dto.setCategory("Miscellaneous"); } + return dto; } - @ObjectFactory - public PluginDto createDto(PluginInformation pluginInformation) { + private PluginDto createDto(Plugin plugin) { Links.Builder linksBuilder; - if (pluginInformation.getState() != null && pluginInformation.getState().equals(PluginState.AVAILABLE)) { + + PluginInformation pluginInformation = plugin.getDescriptor().getInformation(); + + if (plugin.getState() != null && plugin.getState().equals(PluginState.AVAILABLE)) { linksBuilder = linkingTo() .self(resourceLinks.availablePlugin() .self(pluginInformation.getName(), pluginInformation.getVersion())); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index c19c19b377..4e91f5123b 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -35,685 +35,41 @@ package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- -import com.github.legman.Subscribe; - -import com.google.common.base.Predicate; -import com.google.common.io.Files; -import com.google.inject.Inject; import com.google.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import sonia.scm.SCMContextProvider; -import sonia.scm.cache.Cache; -import sonia.scm.cache.CacheManager; -import sonia.scm.config.ScmConfiguration; -import sonia.scm.config.ScmConfigurationChangedEvent; -import sonia.scm.io.ZipUnArchiver; -import sonia.scm.util.AssertUtil; -import sonia.scm.util.IOUtil; -import sonia.scm.util.SystemUtil; -import sonia.scm.util.Util; -import sonia.scm.version.Version; - //~--- JDK imports ------------------------------------------------------------ - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; - -import java.net.URLEncoder; - -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -import javax.xml.bind.JAXB; - -import sonia.scm.net.ahc.AdvancedHttpClient; - -import static sonia.scm.plugin.PluginCenterDtoMapper.*; +import java.util.List; +import java.util.Optional; /** - * TODO replace aether stuff. - * TODO check AdvancedPluginConfiguration from 1.x * * @author Sebastian Sdorra */ @Singleton -public class DefaultPluginManager implements PluginManager -{ +public class DefaultPluginManager implements PluginManager { - /** Field description */ - public static final String CACHE_NAME = "sonia.cache.plugins"; - - /** Field description */ - public static final String ENCODING = "UTF-8"; - - /** the logger for DefaultPluginManager */ - private static final Logger logger = - LoggerFactory.getLogger(DefaultPluginManager.class); - - /** enable or disable remote plugins */ - private static final boolean REMOTE_PLUGINS_ENABLED = true; - - /** Field description */ - public static final Predicate<PluginInformation> FILTER_UPDATES = - new StatePluginPredicate(PluginState.UPDATE_AVAILABLE); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * @param context - * @param configuration - * @param pluginLoader - * @param cacheManager - * @param httpClient - */ - @Inject - public DefaultPluginManager(SCMContextProvider context, - ScmConfiguration configuration, PluginLoader pluginLoader, - CacheManager cacheManager, AdvancedHttpClient httpClient) - { - this.context = context; - this.configuration = configuration; - this.cache = cacheManager.getCache(CACHE_NAME); - this.httpClient = httpClient; - installedPlugins = new HashMap<>(); - - for (InstalledPlugin wrapper : pluginLoader.getInstalledPlugins()) - { - InstalledPluginDescriptor plugin = wrapper.getDescriptor(); - PluginInformation info = plugin.getInformation(); - - if ((info != null) && info.isValid()) - { - installedPlugins.put(info.getId(), plugin); - } - } - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - */ @Override - public void clearCache() - { - if (logger.isDebugEnabled()) - { - logger.debug("clear plugin cache"); - } - - cache.clear(); + public Optional<AvailablePlugin> getAvailable(String name) { + return Optional.empty(); } - /** - * Method description - * - * - * @param config - */ - @Subscribe - public void configChanged(ScmConfigurationChangedEvent config) - { - clearCache(); - } - - /** - * Method description - * - * - * @param id - */ @Override - public void install(String id) - { - PluginPermissions.manage().check(); - - PluginCenter center = getPluginCenter(); - - for (PluginInformation plugin : center.getPlugins()) - { - String pluginId = plugin.getId(); - - if (Util.isNotEmpty(pluginId) && pluginId.equals(id)) - { - plugin.setState(PluginState.INSTALLED); - - // ugly workaround - InstalledPluginDescriptor newPlugin = new InstalledPluginDescriptor(); - - // TODO check - // newPlugin.setInformation(plugin); - installedPlugins.put(id, newPlugin); - } - } + public Optional<InstalledPlugin> getInstalled(String name) { + return Optional.empty(); } - /** - * Method description - * - * - * @param packageStream - * - * @throws IOException - */ @Override - public void installPackage(InputStream packageStream) throws IOException - { - PluginPermissions.manage().check(); - - File tempDirectory = Files.createTempDir(); - - try - { - new ZipUnArchiver().extractArchive(packageStream, tempDirectory); - - InstalledPluginDescriptor plugin = JAXB.unmarshal(new File(tempDirectory, "plugin.xml"), - InstalledPluginDescriptor.class); - - PluginCondition condition = plugin.getCondition(); - - if ((condition != null) &&!condition.isSupported()) - { - throw new PluginConditionFailedException(condition); - } - - /* - * AetherPluginHandler aph = new AetherPluginHandler(this, context, - * configuration); - * Collection<PluginRepository> repositories = - * Sets.newHashSet(new PluginRepository("package-repository", - * "file://".concat(tempDirectory.getAbsolutePath()))); - * - * aph.setPluginRepositories(repositories); - * - * aph.install(plugin.getInformation().getId()); - */ - plugin.getInformation().setState(PluginState.INSTALLED); - installedPlugins.put(plugin.getInformation().getId(), plugin); - - } - finally - { - IOUtil.delete(tempDirectory); - } + public List<InstalledPlugin> getInstalled() { + return null; } - /** - * Method description - * - * - * @param id - */ @Override - public void uninstall(String id) - { - PluginPermissions.manage().check(); - - InstalledPluginDescriptor plugin = installedPlugins.get(id); - - if (plugin == null) - { - String pluginPrefix = getPluginIdPrefix(id); - - for (String nid : installedPlugins.keySet()) - { - if (nid.startsWith(pluginPrefix)) - { - id = nid; - plugin = installedPlugins.get(nid); - - break; - } - } - } - - if (plugin == null) - { - throw new PluginNotInstalledException(id.concat(" is not install")); - } - - /* - * if (pluginHandler == null) - * { - * getPluginCenter(); - * } - * - * pluginHandler.uninstall(id); - */ - installedPlugins.remove(id); - preparePlugins(getPluginCenter()); + public List<AvailablePlugin> getAvailable() { + return null; } - /** - * Method description - * - * - * @param id - */ @Override - public void update(String id) - { - PluginPermissions.manage().check(); + public void install(String name) { - String[] idParts = id.split(":"); - String name = idParts[0]; - PluginInformation installed = null; - - for (PluginInformation info : getInstalled()) - { - if (name.equals(info.getName())) - { - installed = info; - - break; - } - } - - if (installed == null) - { - StringBuilder msg = new StringBuilder(name); - - msg.append(" is not install"); - - throw new PluginNotInstalledException(msg.toString()); - } - - uninstall(installed.getId()); - install(id); } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param id - * - * @return - */ - @Override - public PluginInformation get(String id) - { - PluginPermissions.read().check(); - - PluginInformation result = null; - - for (PluginInformation info : getPluginCenter().getPlugins()) - { - if (id.equals(info.getId())) - { - result = info; - - break; - } - } - - return result; - } - - /** - * Method description - * - * - * @param predicate - * - * @return - */ - @Override - public Set<PluginInformation> get(Predicate<PluginInformation> predicate) - { - AssertUtil.assertIsNotNull(predicate); - PluginPermissions.read().check(); - - Set<PluginInformation> infoSet = new HashSet<>(); - - filter(infoSet, getInstalled(), predicate); - filter(infoSet, getPluginCenter().getPlugins(), predicate); - - return infoSet; - } - - /** - * Method description - * - * - * @return - */ - @Override - public Collection<PluginInformation> getAll() - { - PluginPermissions.read().check(); - - Set<PluginInformation> infoSet = getInstalled(); - - infoSet.addAll(getPluginCenter().getPlugins()); - - return infoSet; - } - - /** - * Method description - * - * - * @return - */ - @Override - public Collection<PluginInformation> getAvailable() - { - PluginPermissions.read().check(); - - Set<PluginInformation> availablePlugins = new HashSet<>(); - Set<PluginInformation> centerPlugins = getPluginCenter().getPlugins(); - - for (PluginInformation info : centerPlugins) - { - if (!installedPlugins.containsKey(info.getName())) - { - availablePlugins.add(info); - } - } - - return availablePlugins; - } - - /** - * Method description - * - * - * @return - */ - @Override - public Set<PluginInformation> getAvailableUpdates() - { - PluginPermissions.read().check(); - - return get(FILTER_UPDATES); - } - - /** - * Method description - * - * - * @return - */ - @Override - public Set<PluginInformation> getInstalled() - { - PluginPermissions.read().check(); - - Set<PluginInformation> infoSet = new LinkedHashSet<>(); - - for (InstalledPluginDescriptor plugin : installedPlugins.values()) - { - infoSet.add(plugin.getInformation()); - } - - return infoSet; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * - * @param url - * @return - */ - private String buildPluginUrl(String url) - { - String os = SystemUtil.getOS(); - String arch = SystemUtil.getArch(); - - try - { - os = URLEncoder.encode(os, ENCODING); - } - catch (UnsupportedEncodingException ex) - { - logger.error(ex.getMessage(), ex); - } - - return url.replace("{version}", context.getVersion()).replace("{os}", - os).replace("{arch}", arch); - } - - /** - * Method description - * - * - * @param target - * @param source - * @param predicate - */ - private void filter(Set<PluginInformation> target, - Collection<PluginInformation> source, - Predicate<PluginInformation> predicate) - { - for (PluginInformation info : source) - { - if (predicate.apply(info)) - { - target.add(info); - } - } - } - - /** - * Method description - * - * - * @param available - */ - private void preparePlugin(PluginInformation available) - { - PluginState state = PluginState.AVAILABLE; - - for (PluginInformation installed : getInstalled()) - { - if (isSamePlugin(available, installed)) - { - if (installed.getVersion().equals(available.getVersion())) - { - state = PluginState.INSTALLED; - } - else if (isNewer(available, installed)) - { - state = PluginState.UPDATE_AVAILABLE; - } - else - { - state = PluginState.NEWER_VERSION_INSTALLED; - } - - break; - } - } - - available.setState(state); - } - - /** - * Method description - * - * - * @param pc - */ - private void preparePlugins(PluginCenter pc) - { - Set<PluginInformation> infoSet = pc.getPlugins(); - - if (infoSet != null) - { - Iterator<PluginInformation> pit = infoSet.iterator(); - - while (pit.hasNext()) - { - PluginInformation available = pit.next(); - - if (isCorePluging(available)) - { - pit.remove(); - } - else - { - preparePlugin(available); - } - } - } - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - private PluginCenter getPluginCenter() - { - PluginCenter center = cache.get(PluginCenter.class.getName()); - - if (center == null) - { - synchronized (DefaultPluginManager.class) - { - String pluginUrl = buildPluginUrl(configuration.getPluginUrl()); - logger.info("fetch plugin information from {}", pluginUrl); - - if (REMOTE_PLUGINS_ENABLED && Util.isNotEmpty(pluginUrl)) - { - try - { - center = new PluginCenter(); - PluginCenterDto pluginCenterDto = httpClient.get(pluginUrl).request().contentFromJson(PluginCenterDto.class); - Set<PluginInformation> pluginInformationSet = map(pluginCenterDto.getEmbedded().getPlugins()); - center.setPlugins(pluginInformationSet); - preparePlugins(center); - cache.put(PluginCenter.class.getName(), center); - } - catch (IOException ex) - { - logger.error("could not load plugins from plugin center", ex); - } - } - } - if(center == null) { - center = new PluginCenter(); - } - } - - return center; - } - - /** - * Method description - * - * - * @param pluginId - * - * @return - */ - private String getPluginIdPrefix(String pluginId) - { - return pluginId.substring(0, pluginId.lastIndexOf(':')); - } - - /** - * Method description - * - * - * @param available - * - * @return - */ - private boolean isCorePluging(PluginInformation available) - { - boolean core = false; - - for (InstalledPluginDescriptor installedPlugin : installedPlugins.values()) - { - PluginInformation installed = installedPlugin.getInformation(); - - if (isSamePlugin(available, installed) - && (installed.getState() == PluginState.CORE)) - { - core = true; - - break; - } - } - - return core; - } - - /** - * Method description - * - * - * @param available - * @param installed - * - * @return - */ - private boolean isNewer(PluginInformation available, - PluginInformation installed) - { - boolean result = false; - Version version = Version.parse(available.getVersion()); - - if (version != null) - { - result = version.isNewer(installed.getVersion()); - } - - return result; - } - - /** - * Method description - * - * - * @param p1 - * @param p2 - * - * @return - */ - private boolean isSamePlugin(PluginInformation p1, PluginInformation p2) - { - return p1.getName().equals(p2.getName()); - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final Cache<String, PluginCenter> cache; - - /** Field description */ - private final AdvancedHttpClient httpClient; - - /** Field description */ - private final ScmConfiguration configuration; - - /** Field description */ - private final SCMContextProvider context; - - /** Field description */ - private final Map<String, InstalledPluginDescriptor> installedPlugins; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/OverviewPluginPredicate.java b/scm-webapp/src/main/java/sonia/scm/plugin/OverviewPluginPredicate.java deleted file mode 100644 index b242813e0d..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/plugin/OverviewPluginPredicate.java +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.plugin; - -import com.google.common.base.Predicate; - -/** - * - * @author Sebastian Sdorra - */ -public class OverviewPluginPredicate implements Predicate<PluginInformation> -{ - - /** Field description */ - public static final OverviewPluginPredicate INSTANCE = - new OverviewPluginPredicate(); - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param plugin - * - * @return - */ - @Override - public boolean apply(PluginInformation plugin) - { - return plugin.getState() != PluginState.NEWER_VERSION_INSTALLED; - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index ed1bc7643a..5ae4a32897 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -237,7 +237,7 @@ public final class PluginProcessor } InstalledPlugin plugin = - createPluginWrapper(createParentPluginClassLoader(classLoader, parents), + createPlugin(createParentPluginClassLoader(classLoader, parents), smp); if (plugin != null) @@ -431,73 +431,36 @@ public final class PluginProcessor return result; } - /** - * Method description - * - * - * - * @param classLoader - * @param descriptor - * - * @return - */ - private InstalledPluginDescriptor createPlugin(ClassLoader classLoader, Path descriptor) - { + private InstalledPluginDescriptor createDescriptor(ClassLoader classLoader, Path descriptor) { ClassLoader ctxcl = Thread.currentThread().getContextClassLoader(); - Thread.currentThread().setContextClassLoader(classLoader); - - try - { - return (InstalledPluginDescriptor) context.createUnmarshaller().unmarshal( - descriptor.toFile()); - } - catch (JAXBException ex) - { - throw new PluginLoadException( - "could not load plugin desriptor ".concat(descriptor.toString()), ex); - } - finally - { + try { + return (InstalledPluginDescriptor) context.createUnmarshaller().unmarshal(descriptor.toFile()); + } catch (JAXBException ex) { + throw new PluginLoadException("could not load plugin desriptor ".concat(descriptor.toString()), ex); + } finally { Thread.currentThread().setContextClassLoader(ctxcl); } } - /** - * Method description - * - * - * @param classLoader - * @param smp - * - * @return - * - * @throws IOException - */ - private InstalledPlugin createPluginWrapper(ClassLoader classLoader, - ExplodedSmp smp) - throws IOException - { - InstalledPlugin wrapper = null; + private InstalledPlugin createPlugin(ClassLoader classLoader, ExplodedSmp smp) throws IOException { + InstalledPlugin plugin = null; Path directory = smp.getPath(); - Path descriptor = directory.resolve(PluginConstants.FILE_DESCRIPTOR); + Path descriptorPath = directory.resolve(PluginConstants.FILE_DESCRIPTOR); - if (Files.exists(descriptor)) - { + if (Files.exists(descriptorPath)) { ClassLoader cl = createClassLoader(classLoader, smp); - InstalledPluginDescriptor plugin = createPlugin(cl, descriptor); + InstalledPluginDescriptor descriptor = createDescriptor(cl, descriptorPath); WebResourceLoader resourceLoader = createWebResourceLoader(directory); - wrapper = new InstalledPlugin(plugin, cl, resourceLoader, directory); - } - else - { + plugin = new InstalledPlugin(descriptor, cl, resourceLoader, directory); + } else { logger.warn("found plugin directory without plugin descriptor"); } - return wrapper; + return plugin; } /** diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 57564999ef..54954c19d7 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -16,6 +16,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.AvailablePluginDescriptor; +import sonia.scm.plugin.PluginCondition; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginState; @@ -27,6 +30,7 @@ import javax.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import java.util.Collections; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -87,10 +91,10 @@ class AvailablePluginResourceTest { @Test void getAvailablePlugins() throws URISyntaxException, UnsupportedEncodingException { - PluginInformation pluginInformation = new PluginInformation(); - pluginInformation.setState(PluginState.AVAILABLE); - when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(pluginInformation)); - when(collectionMapper.map(Collections.singletonList(pluginInformation))).thenReturn(new MockedResultDto()); + AvailablePlugin plugin = createPlugin(); + + when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(plugin)); + when(collectionMapper.mapAvailable(Collections.singletonList(plugin))).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); request.accept(VndMediaType.PLUGIN_COLLECTION); @@ -105,16 +109,18 @@ class AvailablePluginResourceTest { @Test void getAvailablePlugin() throws UnsupportedEncodingException, URISyntaxException { PluginInformation pluginInformation = new PluginInformation(); - pluginInformation.setState(PluginState.AVAILABLE); pluginInformation.setName("pluginName"); pluginInformation.setVersion("2.0.0"); - when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(pluginInformation)); + + AvailablePlugin plugin = createPlugin(pluginInformation); + + when(pluginManager.getAvailable("pluginName")).thenReturn(Optional.of(plugin)); PluginDto pluginDto = new PluginDto(); pluginDto.setName("pluginName"); - when(mapper.map(pluginInformation)).thenReturn(pluginDto); + when(mapper.map(plugin)).thenReturn(pluginDto); - MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName/2.0.0"); + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); @@ -126,17 +132,26 @@ class AvailablePluginResourceTest { @Test void installPlugin() throws URISyntaxException { - MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/2.0.0/install"); + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/install"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); - verify(pluginManager).install("pluginName:2.0.0"); + verify(pluginManager).install("pluginName"); assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); } } + private AvailablePlugin createPlugin() { + return createPlugin(new PluginInformation()); + } + + private AvailablePlugin createPlugin(PluginInformation pluginInformation) { + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor(pluginInformation, new PluginCondition(), Collections.emptySet()); + return new AvailablePlugin(descriptor); + } + @Nested class WithoutAuthorization { @@ -156,7 +171,7 @@ class AvailablePluginResourceTest { @Test void shouldNotGetAvailablePluginIfMissingPermission() throws URISyntaxException { - MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName/2.0.0"); + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); @@ -166,7 +181,7 @@ class AvailablePluginResourceTest { @Test void shouldNotInstallPluginIfMissingPermission() throws URISyntaxException { ThreadContext.unbindSubject(); - MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/2.0.0/install"); + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/install"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index ce781c7c32..c5868a1211 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -16,11 +16,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginInformation; -import sonia.scm.plugin.PluginLoader; -import sonia.scm.plugin.PluginState; -import sonia.scm.plugin.InstalledPlugin; +import sonia.scm.plugin.PluginManager; import sonia.scm.web.VndMediaType; import javax.inject.Provider; @@ -28,12 +27,12 @@ import javax.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import java.util.Collections; +import java.util.Optional; 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.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class InstalledPluginResourceTest { @@ -46,15 +45,15 @@ class InstalledPluginResourceTest { @Mock Provider<AvailablePluginResource> availablePluginResourceProvider; - @Mock - private PluginLoader pluginLoader; - @Mock private PluginDtoCollectionMapper collectionMapper; @Mock private PluginDtoMapper mapper; + @Mock + private PluginManager pluginManager; + @InjectMocks InstalledPluginResource installedPluginResource; @@ -86,9 +85,9 @@ class InstalledPluginResourceTest { @Test void getInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { - InstalledPlugin installedPlugin = new InstalledPlugin(null, null, null, null); - when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(installedPlugin)); - when(collectionMapper.map(Collections.singletonList(installedPlugin))).thenReturn(new MockedResultDto()); + InstalledPlugin installedPlugin = createPlugin(); + when(pluginManager.getInstalled()).thenReturn(Collections.singletonList(installedPlugin)); + when(collectionMapper.mapInstalled(Collections.singletonList(installedPlugin))).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); request.accept(VndMediaType.PLUGIN_COLLECTION); @@ -105,10 +104,9 @@ class InstalledPluginResourceTest { PluginInformation pluginInformation = new PluginInformation(); pluginInformation.setVersion("2.0.0"); pluginInformation.setName("pluginName"); - pluginInformation.setState(PluginState.INSTALLED); - InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, pluginInformation, null, null, false, null); - InstalledPlugin installedPlugin = new InstalledPlugin(plugin, null, null, null); - when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(installedPlugin)); + InstalledPlugin installedPlugin = createPlugin(pluginInformation); + + when(pluginManager.getInstalled("pluginName")).thenReturn(Optional.of(installedPlugin)); PluginDto pluginDto = new PluginDto(); pluginDto.setName("pluginName"); @@ -125,6 +123,18 @@ class InstalledPluginResourceTest { } } + private InstalledPlugin createPlugin() { + return createPlugin(new PluginInformation()); + } + + private InstalledPlugin createPlugin(PluginInformation information) { + InstalledPlugin plugin = mock(InstalledPlugin.class); + InstalledPluginDescriptor descriptor = mock(InstalledPluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(information); + lenient().when(plugin.getDescriptor()).thenReturn(descriptor); + return plugin; + } + @Nested class WithoutAuthorization { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java index 97b46603d3..df3b8e101b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java @@ -4,13 +4,19 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.plugin.Plugin; +import sonia.scm.plugin.PluginDescriptor; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginState; import java.net.URI; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.in; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class PluginDtoMapperTest { @@ -25,7 +31,8 @@ class PluginDtoMapperTest { void shouldMapInformation() { PluginInformation information = createPluginInformation(); - PluginDto dto = mapper.map(information); + PluginDto dto = new PluginDto(); + mapper.map(information, dto); assertThat(dto.getName()).isEqualTo("scm-cas-plugin"); assertThat(dto.getVersion()).isEqualTo("1.0.0"); @@ -48,41 +55,51 @@ class PluginDtoMapperTest { @Test void shouldAppendInstalledSelfLink() { - PluginInformation information = createPluginInformation(); - information.setState(PluginState.INSTALLED); + Plugin plugin = createPlugin(PluginState.INSTALLED); - PluginDto dto = mapper.map(information); + PluginDto dto = mapper.map(plugin); assertThat(dto.getLinks().getLinkBy("self").get().getHref()) .isEqualTo("https://hitchhiker.com/v2/plugins/installed/scm-cas-plugin"); } @Test void shouldAppendAvailableSelfLink() { - PluginInformation information = createPluginInformation(); - information.setState(PluginState.AVAILABLE); + Plugin plugin = createPlugin(PluginState.AVAILABLE); - PluginDto dto = mapper.map(information); + PluginDto dto = mapper.map(plugin); assertThat(dto.getLinks().getLinkBy("self").get().getHref()) - .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/1.0.0"); + .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin"); } @Test void shouldAppendInstallLink() { - PluginInformation information = createPluginInformation(); - information.setState(PluginState.AVAILABLE); + Plugin plugin = createPlugin(PluginState.AVAILABLE); - PluginDto dto = mapper.map(information); + PluginDto dto = mapper.map(plugin); assertThat(dto.getLinks().getLinkBy("install").get().getHref()) - .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/1.0.0/install"); + .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/install"); } @Test void shouldReturnMiscellaneousIfCategoryIsNull() { PluginInformation information = createPluginInformation(); information.setCategory(null); - - PluginDto dto = mapper.map(information); + Plugin plugin = createPlugin(information, PluginState.AVAILABLE); + PluginDto dto = mapper.map(plugin); assertThat(dto.getCategory()).isEqualTo("Miscellaneous"); } + private Plugin createPlugin(PluginState state) { + return createPlugin(createPluginInformation(), state); + } + + private Plugin createPlugin(PluginInformation information, PluginState state) { + Plugin plugin = Mockito.mock(Plugin.class); + when(plugin.getState()).thenReturn(state); + PluginDescriptor descriptor = mock(PluginDescriptor.class); + when(descriptor.getInformation()).thenReturn(information); + when(plugin.getDescriptor()).thenReturn(descriptor); + return plugin; + } + } From 9d66f146277ce401734dc5b81a5139faf1563b45 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 12:29:59 +0200 Subject: [PATCH 096/135] implement simplified PluginManager API --- .../sonia/scm/plugin/PluginInformation.java | 4 - .../scm/plugin/DefaultPluginManager.java | 36 ++++- .../java/sonia/scm/plugin/PluginCenter.java | 55 +++++++ .../scm/plugin/PluginCenterDtoMapper.java | 22 +-- .../sonia/scm/plugin/PluginCenterLoader.java | 42 +++++ .../scm/plugin/DefaultPluginManagerTest.java | 149 ++++++++++++++++++ .../scm/plugin/PluginCenterDtoMapperTest.java | 44 ++++-- .../scm/plugin/PluginCenterLoaderTest.java | 50 ++++++ .../sonia/scm/plugin/PluginCenterTest.java | 73 +++++++++ 9 files changed, 443 insertions(+), 32 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index a9ae3b07f6..b669cb63fa 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -70,7 +70,6 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea private String author; private String category; private String avatarUrl; - private PluginCondition condition; @Override public PluginInformation clone() { @@ -82,9 +81,6 @@ public class PluginInformation implements PermissionObject, Validateable, Clonea clone.setAuthor(author); clone.setCategory(category); clone.setAvatarUrl(avatarUrl); - if (condition != null) { - clone.setCondition(condition.clone()); - } return clone; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 4e91f5123b..e465c8306e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -35,11 +35,15 @@ package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; //~--- JDK imports ------------------------------------------------------------ +import javax.inject.Inject; import java.util.List; import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; /** * @@ -48,24 +52,48 @@ import java.util.Optional; @Singleton public class DefaultPluginManager implements PluginManager { + private final PluginLoader loader; + private final PluginCenter center; + + @Inject + public DefaultPluginManager(PluginLoader loader, PluginCenter center) { + this.loader = loader; + this.center = center; + } + @Override public Optional<AvailablePlugin> getAvailable(String name) { - return Optional.empty(); + return center.getAvailable() + .stream() + .filter(filterByName(name)) + .filter(this::isNotInstalled) + .findFirst(); } @Override public Optional<InstalledPlugin> getInstalled(String name) { - return Optional.empty(); + return loader.getInstalledPlugins() + .stream() + .filter(filterByName(name)) + .findFirst(); } @Override public List<InstalledPlugin> getInstalled() { - return null; + return ImmutableList.copyOf(loader.getInstalledPlugins()); } @Override public List<AvailablePlugin> getAvailable() { - return null; + return center.getAvailable().stream().filter(this::isNotInstalled).collect(Collectors.toList()); + } + + private <T extends Plugin> Predicate<T> filterByName(String name) { + return (plugin) -> name.equals(plugin.getDescriptor().getInformation().getName()); + } + + private boolean isNotInstalled(AvailablePlugin availablePlugin) { + return !getInstalled(availablePlugin.getDescriptor().getInformation().getName()).isPresent(); } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java new file mode 100644 index 0000000000..a3817eba0f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java @@ -0,0 +1,55 @@ +package sonia.scm.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.util.HttpUtil; +import sonia.scm.util.SystemUtil; + +import javax.inject.Inject; +import java.util.Set; + +public class PluginCenter { + + private static final String CACHE_NAME = "sonia.cache.plugins"; + + private static final Logger LOG = LoggerFactory.getLogger(PluginCenter.class); + + private final SCMContextProvider context; + private final ScmConfiguration configuration; + private final PluginCenterLoader loader; + private final Cache<String, Set<AvailablePlugin>> cache; + + @Inject + public PluginCenter(SCMContextProvider context, CacheManager cacheManager, ScmConfiguration configuration, PluginCenterLoader loader) { + this.context = context; + this.configuration = configuration; + this.loader = loader; + this.cache = cacheManager.getCache(CACHE_NAME); + } + + synchronized Set<AvailablePlugin> getAvailable() { + String url = buildPluginUrl(configuration.getPluginUrl()); + Set<AvailablePlugin> plugins = cache.get(url); + if (plugins == null) { + LOG.debug("no cached available plugins found, start fetching"); + plugins = loader.load(url); + cache.put(url, plugins); + } else { + LOG.debug("return available plugins from cache"); + } + return plugins; + } + + private String buildPluginUrl(String url) { + String os = HttpUtil.encode(SystemUtil.getOS()); + String arch = SystemUtil.getArch(); + return url.replace("{version}", context.getVersion()) + .replace("{os}", os) + .replace("{arch}", arch); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java index ea445b3ede..972afa3099 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -1,26 +1,26 @@ package sonia.scm.plugin; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; import java.util.HashSet; -import java.util.List; import java.util.Set; @Mapper -public interface PluginCenterDtoMapper { +public abstract class PluginCenterDtoMapper { - @Mapping(source = "conditions", target = "condition") - PluginInformation map(PluginCenterDto.Plugin plugin); + static final PluginCenterDtoMapper INSTANCE = Mappers.getMapper(PluginCenterDtoMapper.class); - PluginCondition map(PluginCenterDto.Condition condition); + abstract PluginInformation map(PluginCenterDto.Plugin plugin); + abstract PluginCondition map(PluginCenterDto.Condition condition); - static Set<PluginInformation> map(List<PluginCenterDto.Plugin> dtos) { - PluginCenterDtoMapper mapper = Mappers.getMapper(PluginCenterDtoMapper.class); - Set<PluginInformation> plugins = new HashSet<>(); - for (PluginCenterDto.Plugin plugin : dtos) { - plugins.add(mapper.map(plugin)); + Set<AvailablePlugin> map(PluginCenterDto pluginCenterDto) { + Set<AvailablePlugin> plugins = new HashSet<>(); + for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) { + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( + map(plugin), map(plugin.getConditions()), plugin.getDependencies() + ); + plugins.add(new AvailablePlugin(descriptor)); } return plugins; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java new file mode 100644 index 0000000000..2b0928891e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java @@ -0,0 +1,42 @@ +package sonia.scm.plugin; + +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.net.ahc.AdvancedHttpClient; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +class PluginCenterLoader { + + private static final Logger LOG = LoggerFactory.getLogger(PluginCenterLoader.class); + + private final AdvancedHttpClient client; + private final PluginCenterDtoMapper mapper; + + @Inject + public PluginCenterLoader(AdvancedHttpClient client) { + this(client, PluginCenterDtoMapper.INSTANCE); + } + + @VisibleForTesting + PluginCenterLoader(AdvancedHttpClient client, PluginCenterDtoMapper mapper) { + this.client = client; + this.mapper = mapper; + } + + Set<AvailablePlugin> load(String url) { + try { + LOG.info("fetch plugins from {}", url); + PluginCenterDto pluginCenterDto = client.get(url).request().contentFromJson(PluginCenterDto.class); + return mapper.map(pluginCenterDto); + } catch (IOException ex) { + LOG.error("failed to load plugins from plugin center, returning empty list"); + return Collections.emptySet(); + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java new file mode 100644 index 0000000000..22fe370915 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -0,0 +1,149 @@ +package sonia.scm.plugin; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.checkerframework.checker.nullness.Opt; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultPluginManagerTest { + + @Mock + private PluginLoader loader; + + @Mock + private PluginCenter center; + + @InjectMocks + private DefaultPluginManager manager; + + @Test + void shouldReturnInstalledPlugins() { + InstalledPlugin review = createInstalled("scm-review-plugin"); + InstalledPlugin git = createInstalled("scm-git-plugin"); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); + + List<InstalledPlugin> installed = manager.getInstalled(); + assertThat(installed).containsOnly(review, git); + } + + @Test + void shouldReturnReviewPlugin() { + InstalledPlugin review = createInstalled("scm-review-plugin"); + InstalledPlugin git = createInstalled("scm-git-plugin"); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); + + Optional<InstalledPlugin> plugin = manager.getInstalled("scm-review-plugin"); + assertThat(plugin).contains(review); + } + + @Test + void shouldReturnEmptyForNonInstalledPlugin() { + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of()); + + Optional<InstalledPlugin> plugin = manager.getInstalled("scm-review-plugin"); + assertThat(plugin).isEmpty(); + } + + @Test + void shouldReturnAvailablePlugins() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin"); + + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + List<AvailablePlugin> available = manager.getAvailable(); + assertThat(available).containsOnly(review, git); + } + + @Test + void shouldFilterOutAllInstalled() { + InstalledPlugin installedGit = createInstalled("scm-git-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); + + AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + List<AvailablePlugin> available = manager.getAvailable(); + assertThat(available).containsOnly(review); + } + + @Test + void shouldReturnAvailable() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); + assertThat(available).contains(git); + } + + @Test + void shouldReturnEmptyForNonExistingAvailable() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); + assertThat(available).isEmpty(); + } + + @Test + void shouldReturnEmptyForInstalledPlugin() { + InstalledPlugin installedGit = createInstalled("scm-git-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); + + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); + assertThat(available).isEmpty(); + } + + private AvailablePlugin createAvailable(String name) { + PluginInformation information = new PluginInformation(); + information.setName(name); + return createAvailable(information); + } + + private InstalledPlugin createInstalled(String name) { + PluginInformation information = new PluginInformation(); + information.setName(name); + return createInstalled(information); + } + + private InstalledPlugin createInstalled(PluginInformation information) { + InstalledPlugin plugin = mock(InstalledPlugin.class, Answers.RETURNS_DEEP_STUBS); + returnInformation(plugin, information); + return plugin; + } + + private AvailablePlugin createAvailable(PluginInformation information) { + AvailablePlugin plugin = mock(AvailablePlugin.class, Answers.RETURNS_DEEP_STUBS); + returnInformation(plugin, information); + return plugin; + } + + private void returnInformation(Plugin mockedPlugin, PluginInformation information) { + when(mockedPlugin.getDescriptor().getInformation()).thenReturn(information); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index 831e847843..0c0b80ccfa 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -2,6 +2,11 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.util.ArrayList; import java.util.Arrays; @@ -11,11 +16,19 @@ import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; import static sonia.scm.plugin.PluginCenterDto.Plugin; import static sonia.scm.plugin.PluginCenterDto.*; +@ExtendWith(MockitoExtension.class) class PluginCenterDtoMapperTest { + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private PluginCenterDto dto; + + @InjectMocks + private PluginCenterDtoMapperImpl mapper; + @Test void shouldMapSinglePlugin() { Plugin plugin = new Plugin( @@ -31,16 +44,19 @@ class PluginCenterDtoMapperTest { ImmutableSet.of("scm-review-plugin"), new HashMap<>()); - PluginInformation result = PluginCenterDtoMapper.map(Collections.singletonList(plugin)).iterator().next(); + when(dto.getEmbedded().getPlugins()).thenReturn(Collections.singletonList(plugin)); + AvailablePluginDescriptor descriptor = mapper.map(dto).iterator().next().getDescriptor(); + PluginInformation information = descriptor.getInformation(); + PluginCondition condition = descriptor.getCondition(); - assertThat(result.getAuthor()).isEqualTo(plugin.getAuthor()); - assertThat(result.getCategory()).isEqualTo(plugin.getCategory()); - assertThat(result.getVersion()).isEqualTo(plugin.getVersion()); - assertThat(result.getCondition().getArch()).isEqualTo(plugin.getConditions().getArch()); - assertThat(result.getCondition().getMinVersion()).isEqualTo(plugin.getConditions().getMinVersion()); - assertThat(result.getCondition().getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next()); - assertThat(result.getDescription()).isEqualTo(plugin.getDescription()); - assertThat(result.getName()).isEqualTo(plugin.getName()); + assertThat(information.getAuthor()).isEqualTo(plugin.getAuthor()); + assertThat(information.getCategory()).isEqualTo(plugin.getCategory()); + assertThat(information.getVersion()).isEqualTo(plugin.getVersion()); + assertThat(condition.getArch()).isEqualTo(plugin.getConditions().getArch()); + assertThat(condition.getMinVersion()).isEqualTo(plugin.getConditions().getMinVersion()); + assertThat(condition.getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next()); + assertThat(information.getDescription()).isEqualTo(plugin.getDescription()); + assertThat(information.getName()).isEqualTo(plugin.getName()); } @Test @@ -71,12 +87,14 @@ class PluginCenterDtoMapperTest { ImmutableSet.of("scm-review-plugin"), new HashMap<>()); - Set<PluginInformation> resultSet = PluginCenterDtoMapper.map(Arrays.asList(plugin1, plugin2)); + when(dto.getEmbedded().getPlugins()).thenReturn(Arrays.asList(plugin1, plugin2)); - List<PluginInformation> pluginsList = new ArrayList<>(resultSet); + Set<AvailablePlugin> resultSet = mapper.map(dto); - PluginInformation pluginInformation1 = pluginsList.get(1); - PluginInformation pluginInformation2 = pluginsList.get(0); + List<AvailablePlugin> pluginsList = new ArrayList<>(resultSet); + + PluginInformation pluginInformation1 = pluginsList.get(1).getDescriptor().getInformation(); + PluginInformation pluginInformation2 = pluginsList.get(0).getDescriptor().getInformation(); assertThat(pluginInformation1.getAuthor()).isEqualTo(plugin1.getAuthor()); assertThat(pluginInformation1.getVersion()).isEqualTo(plugin1.getVersion()); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java new file mode 100644 index 0000000000..e3ebf995bd --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java @@ -0,0 +1,50 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.net.ahc.AdvancedHttpClient; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PluginCenterLoaderTest { + + private static final String PLUGIN_URL = "https://plugins.hitchhiker.com"; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private AdvancedHttpClient client; + + @Mock + private PluginCenterDtoMapper mapper; + + @InjectMocks + private PluginCenterLoader loader; + + @Test + void shouldFetch() throws IOException { + Set<AvailablePlugin> plugins = Collections.emptySet(); + PluginCenterDto dto = new PluginCenterDto(); + when(client.get(PLUGIN_URL).request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); + when(mapper.map(dto)).thenReturn(plugins); + + Set<AvailablePlugin> fetched = loader.load(PLUGIN_URL); + assertThat(fetched).isSameAs(plugins); + } + + @Test + void shouldReturnEmptySetIfPluginCenterNotBeReached() throws IOException { + when(client.get(PLUGIN_URL).request()).thenThrow(new IOException("failed to fetch")); + + Set<AvailablePlugin> fetch = loader.load(PLUGIN_URL); + assertThat(fetch).isEmpty(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java new file mode 100644 index 0000000000..a76b4cb551 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java @@ -0,0 +1,73 @@ +package sonia.scm.plugin; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.cache.CacheManager; +import sonia.scm.cache.MapCacheManager; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.util.SystemUtil; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PluginCenterTest { + + private static final String PLUGIN_URL_BASE = "https://plugins.hitchhiker.com/"; + private static final String PLUGIN_URL = PLUGIN_URL_BASE + "{version}"; + + @Mock + private PluginCenterLoader loader; + + @Mock + private SCMContextProvider contextProvider; + + private ScmConfiguration configuration; + + private CacheManager cacheManager; + + private PluginCenter pluginCenter; + + @BeforeEach + void setUpPluginCenter() { + when(contextProvider.getVersion()).thenReturn("2.0.0"); + + cacheManager = new MapCacheManager(); + + configuration = new ScmConfiguration(); + configuration.setPluginUrl(PLUGIN_URL); + + pluginCenter = new PluginCenter(contextProvider, cacheManager, configuration, loader); + } + + @Test + void shouldFetchPlugins() { + Set<AvailablePlugin> plugins = new HashSet<>(); + when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(plugins); + + assertThat(pluginCenter.getAvailable()).isSameAs(plugins); + } + + @Test + void shouldCache() { + Set<AvailablePlugin> first = new HashSet<>(); + when(loader.load(anyString())).thenReturn(first, new HashSet<>()); + + assertThat(pluginCenter.getAvailable()).isSameAs(first); + assertThat(pluginCenter.getAvailable()).isSameAs(first); + } + +} From d941fae49c249102b238fee85a99202a5efdcab8 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Tue, 20 Aug 2019 13:29:01 +0200 Subject: [PATCH 097/135] corrected sizing problems and page breaks on mobile pages --- .../repos/changesets/ChangesetButtonGroup.js | 10 +- .../src/repos/changesets/ChangesetRow.js | 131 +++++++++--------- .../src/repos/changesets/ChangesetTagBase.js | 5 +- 3 files changed, 74 insertions(+), 72 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js index 74a4eb0050..57166ece9d 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js @@ -1,9 +1,9 @@ //@flow import React from "react"; -import type {Changeset, Repository} from "@scm-manager/ui-types"; -import {Button, ButtonAddons} from "../../buttons"; -import {createChangesetLink, createSourcesLink} from "./changesets"; -import {translate} from "react-i18next"; +import type { Changeset, Repository } from "@scm-manager/ui-types"; +import { ButtonAddons, Button } from "../../buttons"; +import { createChangesetLink, createSourcesLink } from "./changesets"; +import { translate } from "react-i18next"; type Props = { repository: Repository, @@ -21,7 +21,7 @@ class ChangesetButtonGroup extends React.Component<Props> { const sourcesLink = createSourcesLink(repository, changeset); return ( - <ButtonAddons className="level-item"> + <ButtonAddons className="is-marginless"> <Button link={changesetLink} className="reduced-mobile"> <span className="icon"> <i className="fas fa-exchange-alt" /> diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js index 6de47f0c87..1180bc1c50 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js @@ -1,16 +1,16 @@ //@flow import React from "react"; -import type {Changeset, Repository} from "@scm-manager/ui-types"; +import type { Changeset, Repository } from "@scm-manager/ui-types"; import classNames from "classnames"; -import {Interpolate, translate} from "react-i18next"; +import { Interpolate, translate } from "react-i18next"; import ChangesetId from "./ChangesetId"; import injectSheet from "react-jss"; -import {DateFromNow} from "../.."; +import { DateFromNow } from "../.."; import ChangesetAuthor from "./ChangesetAuthor"; -import {parseDescription} from "./changesets"; -import {AvatarImage, AvatarWrapper} from "../../avatar"; -import {ExtensionPoint} from "@scm-manager/ui-extensions"; +import { parseDescription } from "./changesets"; +import { AvatarWrapper, AvatarImage } from "../../avatar"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; import ChangesetTags from "./ChangesetTags"; import ChangesetButtonGroup from "./ChangesetButtonGroup"; @@ -26,21 +26,22 @@ const styles = { }, avatarFigure: { marginTop: ".25rem", - marginRight: ".5rem", + marginRight: ".5rem" }, avatarImage: { height: "35px", width: "35px" }, - isVcentered: { - marginTop: "auto", - marginBottom: "auto" - }, metadata: { marginLeft: 0 }, - tag: { - marginTop: ".5rem" + isVcentered: { + alignSelf: "center" + }, + flexVcenter: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end" } }; @@ -65,61 +66,65 @@ class ChangesetRow extends React.Component<Props> { return ( <div className={classes.changeset}> - <div className="columns"> + <div className="columns is-gapless is-mobile"> <div className="column is-three-fifths"> - - <h4 className="has-text-weight-bold is-ellipsis-overflow"> - <ExtensionPoint - name="changeset.description" - props={{ changeset, value: description.title }} - renderAll={false} - > - {description.title} - </ExtensionPoint> - </h4> - - <div className="media"> - <AvatarWrapper> - <figure className={classNames(classes.avatarFigure, "media-left")}> - <div className={classNames("image", classes.avatarImage)}> - <AvatarImage person={changeset.author} /> + <div className="columns is-gapless"> + <div className="column is-four-fifths"> + <h4 className="has-text-weight-bold is-ellipsis-overflow"> + <ExtensionPoint + name="changeset.description" + props={{ changeset, value: description.title }} + renderAll={false} + > + {description.title} + </ExtensionPoint> + </h4> + <div className="media"> + <AvatarWrapper> + <figure + className={classNames(classes.avatarFigure, "media-left")} + > + <div className={classNames("image", classes.avatarImage)}> + <AvatarImage person={changeset.author} /> + </div> + </figure> + </AvatarWrapper> + <div className={classNames(classes.metadata, "media-right")}> + <p className="is-hidden-touch"> + <Interpolate + i18nKey="changeset.summary" + id={changesetId} + time={dateFromNow} + /> + </p> + <p className="is-hidden-desktop"> + <Interpolate + i18nKey="changeset.shortSummary" + id={changesetId} + time={dateFromNow} + /> + </p> + <p className="is-size-7"> + <ChangesetAuthor changeset={changeset} /> + </p> </div> - </figure> - </AvatarWrapper> - <div className={classNames(classes.metadata, "media-right")}> - <p className="is-hidden-mobile is-hidden-tablet-only"> - <Interpolate - i18nKey="changeset.summary" - id={changesetId} - time={dateFromNow} - /> - </p> - <p className="is-hidden-desktop"> - <Interpolate - i18nKey="changeset.shortSummary" - id={changesetId} - time={dateFromNow} - /> - </p> - <p className="is-size-7"> - <ChangesetAuthor changeset={changeset} /> - </p> + </div> + </div> + <div className={classNames("column", classes.isVcentered)}> + <ChangesetTags changeset={changeset} /> </div> </div> - </div> - <div className={classNames("column", classes.isVcentered)}> - <ChangesetTags changeset={changeset} /> - <div className="is-pulled-right level"> - <ChangesetButtonGroup repository={repository} changeset={changeset} /> - <div className={classes.isVcentered}> - <ExtensionPoint - name="changeset.right" - props={{ repository, changeset }} - renderAll={true} - /> - </div> - </div> + <div className={classNames("column", classes.flexVcenter)}> + <ChangesetButtonGroup + repository={repository} + changeset={changeset} + /> + <ExtensionPoint + name="changeset.right" + props={{ repository, changeset }} + renderAll={true} + /> </div> </div> </div> diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js index 7fc8dabcd2..a62c6292a3 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js @@ -4,9 +4,6 @@ import injectSheet from "react-jss"; import classNames from "classnames"; const styles = { - tag: { - marginTop: ".5rem" - }, spacing: { marginRight: ".25rem" } @@ -24,7 +21,7 @@ class ChangesetTagBase extends React.Component<Props> { render() { const { icon, label, classes } = this.props; return ( - <span className={classNames(classes.tag, "tag", "is-info")}> + <span className={classNames("tag", "is-info")}> <span className={classNames("fa", icon, classes.spacing)} /> {label} </span> ); From 65b59d1aec41f6931f44e9657f6ebc15b255f67c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 13:40:05 +0200 Subject: [PATCH 098/135] expose plugin dependencies --- .../java/sonia/scm/api/v2/resources/PluginDto.java | 3 +++ .../sonia/scm/api/v2/resources/PluginDtoMapper.java | 1 + .../scm/api/v2/resources/PluginDtoMapperTest.java | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index b096266537..07ccb3203e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.Set; + @Getter @Setter @NoArgsConstructor @@ -18,6 +20,7 @@ public class PluginDto extends HalRepresentation { private String author; private String category; private String avatarUrl; + private Set<String> dependencies; public PluginDto(Links links) { add(links); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index bef5a9b496..193aa3af26 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -22,6 +22,7 @@ public abstract class PluginDtoMapper { public PluginDto map(Plugin plugin) { PluginDto dto = createDto(plugin); + dto.setDependencies(plugin.getDescriptor().getDependencies()); map(plugin.getDescriptor().getInformation(), dto); if (dto.getCategory() == null) { dto.setCategory("Miscellaneous"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java index df3b8e101b..3eaeb7a2cc 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java @@ -1,6 +1,6 @@ package sonia.scm.api.v2.resources; -import org.junit.jupiter.api.BeforeEach; +import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -14,7 +14,6 @@ import sonia.scm.plugin.PluginState; import java.net.URI; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.in; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -89,6 +88,15 @@ class PluginDtoMapperTest { assertThat(dto.getCategory()).isEqualTo("Miscellaneous"); } + @Test + void shouldAppendDependencies() { + Plugin plugin = createPlugin(PluginState.AVAILABLE); + when(plugin.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("one", "two")); + + PluginDto dto = mapper.map(plugin); + assertThat(dto.getDependencies()).containsOnly("one", "two"); + } + private Plugin createPlugin(PluginState state) { return createPlugin(createPluginInformation(), state); } From 3e2c8b7c4b7a241fb8ab092a0734d56d328e47fc Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Tue, 20 Aug 2019 14:23:52 +0200 Subject: [PATCH 099/135] Remove archive flag --- .../sonia/scm/config/ScmConfiguration.java | 16 -- .../java/sonia/scm/repository/Repository.java | 26 +-- .../RepositoryIsNotArchivedException.java | 48 ----- .../sonia/scm/store/repositoryDaoMetadata.xml | 1 - .../java/sonia/scm/it/utils/TestData.java | 1 - .../packages/ui-types/src/Config.js | 1 - scm-ui/public/locales/de/config.json | 2 - scm-ui/public/locales/en/config.json | 2 - .../src/admin/components/form/ConfigForm.js | 4 +- .../admin/components/form/GeneralSettings.js | 1 - scm-ui/src/admin/modules/config.test.js | 2 - scm-ui/src/repos/modules/repos.test.js | 1 - .../sonia/scm/api/v2/resources/ConfigDto.java | 1 - .../api/v2/resources/RepositoryResource.java | 11 +- .../SingleResourceManagerAdapter.java | 31 +-- .../repository/DefaultRepositoryManager.java | 3 - .../AuthorizationChangedEventProducer.java | 3 +- .../MigrateVerbsToPermissionRoles.java | 1 - ...ConfigDtoToScmConfigurationMapperTest.java | 2 - .../resources/RepositoryRootResourceTest.java | 16 -- ...ScmConfigurationToConfigDtoMapperTest.java | 2 - .../sonia/scm/it/RepositoryArchiveITCase.java | 181 ------------------ .../sonia/scm/update/security/config.xml | 1 - 23 files changed, 8 insertions(+), 349 deletions(-) delete mode 100644 scm-core/src/main/java/sonia/scm/repository/RepositoryIsNotArchivedException.java delete mode 100644 scm-webapp/src/test/java/sonia/scm/it/RepositoryArchiveITCase.java diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 95deebfd60..4afbb6a895 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -161,7 +161,6 @@ public class ScmConfiguration implements Configuration { * Authentication realm for basic authentication. */ private String realmDescription = HttpUtil.AUTHENTICATION_REALM; - private boolean enableRepositoryArchive = false; private boolean disableGroupingGrid = false; /** * JavaScript date format from moment.js @@ -218,7 +217,6 @@ public class ScmConfiguration implements Configuration { this.forceBaseUrl = other.forceBaseUrl; this.baseUrl = other.baseUrl; this.disableGroupingGrid = other.disableGroupingGrid; - this.enableRepositoryArchive = other.enableRepositoryArchive; this.skipFailedAuthenticators = other.skipFailedAuthenticators; this.loginAttemptLimit = other.loginAttemptLimit; this.loginAttemptLimitTimeout = other.loginAttemptLimitTimeout; @@ -343,10 +341,6 @@ public class ScmConfiguration implements Configuration { return enableProxy; } - public boolean isEnableRepositoryArchive() { - return enableRepositoryArchive; - } - public boolean isForceBaseUrl() { return forceBaseUrl; } @@ -393,16 +387,6 @@ public class ScmConfiguration implements Configuration { this.enableProxy = enableProxy; } - /** - * Enable or disable the repository archive. Default is disabled. - * - * @param enableRepositoryArchive true to disable the repository archive - * @since 1.14 - */ - public void setEnableRepositoryArchive(boolean enableRepositoryArchive) { - this.enableRepositoryArchive = enableRepositoryArchive; - } - public void setForceBaseUrl(boolean forceBaseUrl) { this.forceBaseUrl = forceBaseUrl; } diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index 8c7000c25a..463085a7ea 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -85,7 +85,6 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per private Set<RepositoryPermission> permissions = new HashSet<>(); @XmlElement(name = "public") private boolean publicReadable = false; - private boolean archived = false; private String type; @@ -216,16 +215,6 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per return type; } - /** - * Returns true if the repository is archived. - * - * @return true if the repository is archived - * @since 1.14 - */ - public boolean isArchived() { - return archived; - } - /** * Returns {@code true} if the repository is healthy. * @@ -264,16 +253,6 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per && ((Util.isEmpty(contact)) || ValidationUtil.isMailAddressValid(contact)); } - /** - * Archive or un archive this repository. - * - * @param archived true to enable archive - * @since 1.14 - */ - public void setArchived(boolean archived) { - this.archived = archived; - } - public void setContact(String contact) { this.contact = contact; } @@ -354,7 +333,6 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per repository.setDescription(description); repository.setPermissions(permissions); repository.setPublicReadable(publicReadable); - repository.setArchived(archived); // do not copy health check results } @@ -383,7 +361,6 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per && Objects.equal(contact, other.contact) && Objects.equal(description, other.description) && Objects.equal(publicReadable, other.publicReadable) - && Objects.equal(archived, other.archived) && Objects.equal(permissions, other.permissions) && Objects.equal(type, other.type) && Objects.equal(creationDate, other.creationDate) @@ -395,7 +372,7 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per @Override public int hashCode() { return Objects.hashCode(id, namespace, name, contact, description, publicReadable, - archived, permissions, type, creationDate, lastModified, properties, + permissions, type, creationDate, lastModified, properties, healthCheckFailures); } @@ -408,7 +385,6 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per .add("contact", contact) .add("description", description) .add("publicReadable", publicReadable) - .add("archived", archived) .add("permissions", permissions) .add("type", type) .add("lastModified", lastModified) diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryIsNotArchivedException.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryIsNotArchivedException.java deleted file mode 100644 index a427050633..0000000000 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryIsNotArchivedException.java +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.repository; - -/** - * - * @author Sebastian Sdorra - * - * @since 1.14 - */ -public class RepositoryIsNotArchivedException extends RuntimeException { - - private static final long serialVersionUID = 7728748133123987511L; - - public RepositoryIsNotArchivedException() { - super("Repository could not be deleted, because it is not archived."); - } -} diff --git a/scm-dao-xml/src/test/resources/sonia/scm/store/repositoryDaoMetadata.xml b/scm-dao-xml/src/test/resources/sonia/scm/store/repositoryDaoMetadata.xml index 87aa3775ea..a9e84994dc 100644 --- a/scm-dao-xml/src/test/resources/sonia/scm/store/repositoryDaoMetadata.xml +++ b/scm-dao-xml/src/test/resources/sonia/scm/store/repositoryDaoMetadata.xml @@ -5,6 +5,5 @@ <namespace>space</namespace> <name>existing</name> <public>false</public> - <archived>false</archived> <type>xml</type> </repositories> diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java index d632f13f60..c7d97a6891 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -228,7 +228,6 @@ public class TestData { .add("contact", "zaphod.beeblebrox@hitchhiker.com") .add("description", "Heart of Gold") .add("name", getDefaultRepoName(repositoryType)) - .add("archived", false) .add("type", repositoryType) .build().toString(); } diff --git a/scm-ui-components/packages/ui-types/src/Config.js b/scm-ui-components/packages/ui-types/src/Config.js index 5a9522585f..fcd650a3dc 100644 --- a/scm-ui-components/packages/ui-types/src/Config.js +++ b/scm-ui-components/packages/ui-types/src/Config.js @@ -8,7 +8,6 @@ export type Config = { proxyUser: string | null, enableProxy: boolean, realmDescription: string, - enableRepositoryArchive: boolean, disableGroupingGrid: boolean, dateFormat: string, anonymousAccessEnabled: boolean, diff --git a/scm-ui/public/locales/de/config.json b/scm-ui/public/locales/de/config.json index 0bc220515a..5f598cebbe 100644 --- a/scm-ui/public/locales/de/config.json +++ b/scm-ui/public/locales/de/config.json @@ -36,7 +36,6 @@ }, "general-settings": { "realm-description": "Realm Beschreibung", - "enable-repository-archive": "Repository Archiv aktivieren", "disable-grouping-grid": "Gruppen deaktivieren", "date-format": "Datumsformat", "anonymous-access-enabled": "Anonyme Zugriffe erlauben", @@ -57,7 +56,6 @@ "dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.", "pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", - "enableRepositoryArchiveHelpText": "Repository Archive aktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", "disableGroupingGridHelpText": "Repository Gruppen deaktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", "allowAnonymousAccessHelpText": "Anonyme Benutzer haben Zugriff auf öffentliche Repositories.", "skipFailedAuthenticatorsHelpText": "Die Kette der Authentifikatoren wird nicht beendet, wenn ein Authentifikator einen Benutzer findet, ihn aber nicht erfolgreich authentifizieren kann.", diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index ce0f7252df..6b602a17be 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -36,7 +36,6 @@ }, "general-settings": { "realm-description": "Realm Description", - "enable-repository-archive": "Enable Repository Archive", "disable-grouping-grid": "Disable Grouping Grid", "date-format": "Date Format", "anonymous-access-enabled": "Anonymous Access Enabled", @@ -57,7 +56,6 @@ "dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.", "pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", "enableForwardingHelpText": "Enable mod_proxy port forwarding.", - "enableRepositoryArchiveHelpText": "Enable repository archives. A complete page reload is required after a change of this value.", "disableGroupingGridHelpText": "Disable repository Groups. A complete page reload is required after a change of this value.", "allowAnonymousAccessHelpText": "Anonymous users have read access on public repositories.", "skipFailedAuthenticatorsHelpText": "Do not stop the authentication chain, if an authenticator finds the user but fails to authenticate the user.", diff --git a/scm-ui/src/admin/components/form/ConfigForm.js b/scm-ui/src/admin/components/form/ConfigForm.js index 25a24c4d28..fbabcf197d 100644 --- a/scm-ui/src/admin/components/form/ConfigForm.js +++ b/scm-ui/src/admin/components/form/ConfigForm.js @@ -16,7 +16,7 @@ type Props = { configUpdatePermission: boolean, namespaceStrategies?: NamespaceStrategies, // context props - t: string => string, + t: string => string }; type State = { @@ -41,7 +41,6 @@ class ConfigForm extends React.Component<Props, State> { proxyUser: null, enableProxy: false, realmDescription: "", - enableRepositoryArchive: false, disableGroupingGrid: false, dateFormat: "", anonymousAccessEnabled: false, @@ -122,7 +121,6 @@ class ConfigForm extends React.Component<Props, State> { namespaceStrategies={namespaceStrategies} loginInfoUrl={config.loginInfoUrl} realmDescription={config.realmDescription} - enableRepositoryArchive={config.enableRepositoryArchive} disableGroupingGrid={config.disableGroupingGrid} dateFormat={config.dateFormat} anonymousAccessEnabled={config.anonymousAccessEnabled} diff --git a/scm-ui/src/admin/components/form/GeneralSettings.js b/scm-ui/src/admin/components/form/GeneralSettings.js index 842d0ed7f4..91354badf1 100644 --- a/scm-ui/src/admin/components/form/GeneralSettings.js +++ b/scm-ui/src/admin/components/form/GeneralSettings.js @@ -8,7 +8,6 @@ import NamespaceStrategySelect from "./NamespaceStrategySelect"; type Props = { realmDescription: string, loginInfoUrl: string, - enableRepositoryArchive: boolean, disableGroupingGrid: boolean, dateFormat: string, anonymousAccessEnabled: boolean, diff --git a/scm-ui/src/admin/modules/config.test.js b/scm-ui/src/admin/modules/config.test.js index 23f58c61ac..b580256a94 100644 --- a/scm-ui/src/admin/modules/config.test.js +++ b/scm-ui/src/admin/modules/config.test.js @@ -35,7 +35,6 @@ const config = { proxyUser: null, enableProxy: false, realmDescription: "SONIA :: SCM Manager", - enableRepositoryArchive: false, disableGroupingGrid: false, dateFormat: "YYYY-MM-DD HH:mm:ss", anonymousAccessEnabled: false, @@ -64,7 +63,6 @@ const configWithNullValues = { proxyUser: null, enableProxy: false, realmDescription: "SONIA :: SCM Manager", - enableRepositoryArchive: false, disableGroupingGrid: false, dateFormat: "YYYY-MM-DD HH:mm:ss", anonymousAccessEnabled: false, diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index ca4b6802b8..31dfc2286c 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -96,7 +96,6 @@ const hitchhikerRestatend: Repository = { description: "restaurant at the end of the universe", namespace: "hitchhiker", name: "restatend", - archived: false, type: "git", _links: { self: { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index 30d936d4c5..36abd239dd 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -19,7 +19,6 @@ public class ConfigDto extends HalRepresentation { private String proxyUser; private boolean enableProxy; private String realmDescription; - private boolean enableRepositoryArchive; private boolean disableGroupingGrid; private String dateFormat; private boolean anonymousAccessEnabled; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index 5bca6a6e16..1cfc8b332b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -5,7 +5,6 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryIsNotArchivedException; import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; @@ -63,7 +62,7 @@ public class RepositoryResource { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; - this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class, this::handleNotArchived); + this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class); this.tagRootResource = tagRootResource; this.branchRootResource = branchRootResource; this.changesetRootResource = changesetRootResource; @@ -212,14 +211,6 @@ public class RepositoryResource { @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()); - } else { - return Optional.empty(); - } - } - private Supplier<Repository> loadBy(String namespace, String name) { NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); return () -> Optional.ofNullable(manager.get(namespaceAndName)).orElseThrow(() -> notFound(entity(namespaceAndName))); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java index 9c6c0300d6..a7b9146d00 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java @@ -7,7 +7,6 @@ import sonia.scm.ModelObject; import sonia.scm.NotFoundException; import javax.ws.rs.core.Response; -import java.util.Optional; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -28,20 +27,11 @@ import static javax.ws.rs.core.Response.Status.BAD_REQUEST; class SingleResourceManagerAdapter<MODEL_OBJECT extends ModelObject, DTO extends HalRepresentation> { - private final Function<Throwable, Optional<Response>> errorHandler; protected final Manager<MODEL_OBJECT> manager; protected final Class<MODEL_OBJECT> type; SingleResourceManagerAdapter(Manager<MODEL_OBJECT> manager, Class<MODEL_OBJECT> type) { - this(manager, type, e -> Optional.empty()); - } - - SingleResourceManagerAdapter( - Manager<MODEL_OBJECT> manager, - Class<MODEL_OBJECT> type, - Function<Throwable, Optional<Response>> errorHandler) { this.manager = manager; - this.errorHandler = errorHandler; this.type = type; } @@ -74,12 +64,8 @@ class SingleResourceManagerAdapter<MODEL_OBJECT extends ModelObject, } private Response update(MODEL_OBJECT item) { - try { - manager.modify(item); - return Response.noContent().build(); - } catch (RuntimeException ex) { - return createErrorResponse(ex); - } + manager.modify(item); + return Response.noContent().build(); } private boolean modelObjectWasModifiedConcurrently(MODEL_OBJECT existing, MODEL_OBJECT updated) { @@ -100,22 +86,13 @@ class SingleResourceManagerAdapter<MODEL_OBJECT extends ModelObject, MODEL_OBJECT item = manager.get(name); if (item != null) { - try { - manager.delete(item); - return Response.noContent().build(); - } catch (RuntimeException ex) { - return createErrorResponse(ex); - } + manager.delete(item); + return Response.noContent().build(); } else { return Response.noContent().build(); } } - private Response createErrorResponse(RuntimeException throwable) { - return errorHandler.apply(throwable) - .orElseThrow(() -> throwable); - } - protected String getId(MODEL_OBJECT item) { return item.getId(); } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index 1bcd877620..836d95bf42 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -172,9 +172,6 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { } private void preDelete(Repository toDelete) { - if (configuration.isEnableRepositoryArchive() && !toDelete.isArchived()) { - throw new RepositoryIsNotArchivedException(); - } fireEvent(HandlerEventType.BEFORE_DELETE, toDelete); getHandler(toDelete).delete(toDelete); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java index 3f81377992..fc653efa52 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java @@ -168,8 +168,7 @@ public class AuthorizationChangedEventProducer { } private boolean isAuthorizationDataModified(Repository repository, Repository beforeModification) { - return repository.isArchived() != beforeModification.isArchived() - || repository.isPublicReadable() != beforeModification.isPublicReadable() + return repository.isPublicReadable() != beforeModification.isPublicReadable() || !(repository.getPermissions().containsAll(beforeModification.getPermissions()) && beforeModification.getPermissions().containsAll(repository.getPermissions())); } diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java index 0b96a58385..be40ab3a6d 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java @@ -91,7 +91,6 @@ public class MigrateVerbsToPermissionRoles implements UpdateStep { repository.setHealthCheckFailures(oldRepository.healthCheckFailures); repository.setLastModified(oldRepository.lastModified); repository.setPublicReadable(oldRepository.publicReadable); - repository.setArchived(oldRepository.archived); return repository; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java index e7ae446185..4ec30ca1c3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java @@ -37,7 +37,6 @@ public class ConfigDtoToScmConfigurationMapperTest { assertEquals("user" , config.getProxyUser()); assertTrue(config.isEnableProxy()); assertEquals("realm" , config.getRealmDescription()); - assertTrue(config.isEnableRepositoryArchive()); assertTrue(config.isDisableGroupingGrid()); assertEquals("yyyy" , config.getDateFormat()); assertTrue(config.isAnonymousAccessEnabled()); @@ -61,7 +60,6 @@ public class ConfigDtoToScmConfigurationMapperTest { configDto.setProxyUser("user"); configDto.setEnableProxy(true); configDto.setRealmDescription("realm"); - configDto.setEnableRepositoryArchive(true); configDto.setDisableGroupingGrid(true); configDto.setDateFormat("yyyy"); configDto.setAnonymousAccessEnabled(true); 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 c47250470d..af1e91344a 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 @@ -19,7 +19,6 @@ import org.mockito.Mock; import sonia.scm.PageResult; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryIsNotArchivedException; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -41,7 +40,6 @@ import static javax.servlet.http.HttpServletResponse.SC_CONFLICT; import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static javax.servlet.http.HttpServletResponse.SC_OK; -import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -270,20 +268,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { verify(repositoryManager).delete(anyObject()); } - @Test - public void shouldHandleDeleteIsNotArchivedException() throws Exception { - mockRepository("space", "repo"); - - doThrow(RepositoryIsNotArchivedException.class).when(repositoryManager).delete(anyObject()); - - MockHttpRequest request = MockHttpRequest.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); - MockHttpResponse response = new MockHttpResponse(); - - dispatcher.invoke(request, response); - - assertEquals(SC_PRECONDITION_FAILED, response.getStatus()); - } - @Test public void shouldCreateNewRepositoryInCorrectNamespace() throws Exception { when(repositoryManager.create(any())).thenAnswer(invocation -> { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java index 6ae6d5d2f1..2637b20a2f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java @@ -67,7 +67,6 @@ public class ScmConfigurationToConfigDtoMapperTest { assertEquals("trillian" , dto.getProxyUser()); assertTrue(dto.isEnableProxy()); assertEquals("description" , dto.getRealmDescription()); - assertTrue(dto.isEnableRepositoryArchive()); assertTrue(dto.isDisableGroupingGrid()); assertEquals("dd" , dto.getDateFormat()); assertTrue(dto.isAnonymousAccessEnabled()); @@ -106,7 +105,6 @@ public class ScmConfigurationToConfigDtoMapperTest { config.setProxyUser("trillian"); config.setEnableProxy(true); config.setRealmDescription("description"); - config.setEnableRepositoryArchive(true); config.setDisableGroupingGrid(true); config.setDateFormat("dd"); config.setAnonymousAccessEnabled(true); diff --git a/scm-webapp/src/test/java/sonia/scm/it/RepositoryArchiveITCase.java b/scm-webapp/src/test/java/sonia/scm/it/RepositoryArchiveITCase.java deleted file mode 100644 index bc79b9fa20..0000000000 --- a/scm-webapp/src/test/java/sonia/scm/it/RepositoryArchiveITCase.java +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.it; - - -import com.sun.jersey.api.client.ClientResponse; -import com.sun.jersey.api.client.WebResource; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import sonia.scm.api.v2.resources.ConfigDto; -import sonia.scm.api.v2.resources.RepositoryDto; -import sonia.scm.web.VndMediaType; - -import javax.ws.rs.core.MediaType; -import java.net.URI; -import java.util.Collection; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static sonia.scm.it.IntegrationTestUtil.createAdminClient; -import static sonia.scm.it.IntegrationTestUtil.createResource; -import static sonia.scm.it.IntegrationTestUtil.getLink; -import static sonia.scm.it.IntegrationTestUtil.readJson; -import static sonia.scm.it.IntegrationTestUtil.serialize; -import static sonia.scm.it.RepositoryITUtil.createRepository; -import static sonia.scm.it.RepositoryITUtil.deleteRepository; - -/** - * - * @author Sebastian Sdorra - */ -@RunWith(Parameterized.class) -public class RepositoryArchiveITCase -{ - - /** - * Constructs ... - * - * - * @param type - */ - public RepositoryArchiveITCase(String type) - { - this.type = type; - } - - //~--- methods -------------------------------------------------------------- - - @Parameterized.Parameters(name = "{0}") - public static Collection<String[]> createParameters() { - return IntegrationTestUtil.createRepositoryTypeParameters(); - } - - /** - * Method description - * - */ - @Before - public void createTestRepository() { - client = createAdminClient(); - repository = createRepository(client, readJson("repository-" + type + ".json")); - } - - /** - * Method description - * - */ - @After - public void deleteTestRepository() - { - setArchiveMode(false); - - if (repository != null) - { - deleteRepository(client, repository); - } - } - - /** - * Method description - * - */ - @Test - public void testDeleteAllowed() { - setArchiveMode(true); - - repository.setArchived(true); - - ClientResponse response = createResource(client, - "repositories/" + repository.getNamespace() + "/" + repository.getName()) - .type(VndMediaType.REPOSITORY).put(ClientResponse.class, serialize(repository)); - - assertNotNull(response); - assertEquals(204, response.getStatus()); - response = createResource(client, - "repositories/" + repository.getNamespace() + "/" + repository.getName()).delete(ClientResponse.class); - assertNotNull(response); - assertEquals(204, response.getStatus()); - repository = null; - } - - /** - * Method description - * - */ - @Test - public void testDeleteDenied() - { - setArchiveMode(true); - - URI deleteUrl = getLink(repository, "delete"); - ClientResponse response = createResource(client, deleteUrl).delete(ClientResponse.class); - - assertNotNull(response); - assertEquals(412, response.getStatus()); - response.close(); - } - - /** - * Method description - * - * - * @param archive - */ - private void setArchiveMode(boolean archive) - { - WebResource.Builder resource = createResource(client, "config").type(MediaType.APPLICATION_JSON); - ConfigDto config = resource.get(ConfigDto.class); - - assertNotNull(config); - config.setEnableRepositoryArchive(archive); - - ClientResponse resp = createResource(client, "config").type(VndMediaType.CONFIG).put(ClientResponse.class, config); - - assertNotNull(resp); - assertEquals(204, resp.getStatus()); - } - - /** Field description */ - private ScmClient client; - - /** Field description */ - private RepositoryDto repository; - - /** Field description */ - private String type; -} diff --git a/scm-webapp/src/test/resources/sonia/scm/update/security/config.xml b/scm-webapp/src/test/resources/sonia/scm/update/security/config.xml index a87e80859e..4af47193d9 100644 --- a/scm-webapp/src/test/resources/sonia/scm/update/security/config.xml +++ b/scm-webapp/src/test/resources/sonia/scm/update/security/config.xml @@ -13,7 +13,6 @@ <enableSSL>false</enableSSL> <enablePortForward>false</enablePortForward> <sslPort>8181</sslPort> - <enableRepositoryArchive>false</enableRepositoryArchive> <disableGroupingGrid>false</disableGroupingGrid> <dateFormat>Y-m-d H:i:s</dateFormat> <anonymousAccessEnabled>false</anonymousAccessEnabled> From e24673be0a51c1b84921b706c62af7818a91d05d Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 14:43:48 +0200 Subject: [PATCH 100/135] implemented plugin installation --- .../scm/plugin/AvailablePluginDescriptor.java | 15 +++- .../scm/plugin/DefaultPluginManager.java | 24 ++++- .../sonia/scm/plugin/PluginCenterDto.java | 3 + .../scm/plugin/PluginCenterDtoMapper.java | 3 +- .../PluginChecksumMismatchException.java | 7 ++ .../scm/plugin/PluginDownloadException.java | 7 ++ .../scm/plugin/PluginInstallException.java | 12 +++ .../sonia/scm/plugin/PluginInstaller.java | 63 +++++++++++++ .../AvailablePluginResourceTest.java | 5 +- .../scm/plugin/DefaultPluginManagerTest.java | 49 ++++++++-- .../scm/plugin/PluginCenterDtoMapperTest.java | 13 ++- .../sonia/scm/plugin/PluginInstallerTest.java | 90 +++++++++++++++++++ 12 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java b/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java index b7e7b5e282..1c164a0d81 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java +++ b/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java @@ -1,5 +1,6 @@ package sonia.scm.plugin; +import java.util.Optional; import java.util.Set; /** @@ -10,11 +11,23 @@ public class AvailablePluginDescriptor implements PluginDescriptor { private final PluginInformation information; private final PluginCondition condition; private final Set<String> dependencies; + private final String url; + private final String checksum; - public AvailablePluginDescriptor(PluginInformation information, PluginCondition condition, Set<String> dependencies) { + public AvailablePluginDescriptor(PluginInformation information, PluginCondition condition, Set<String> dependencies, String url, String checksum) { this.information = information; this.condition = condition; this.dependencies = dependencies; + this.url = url; + this.checksum = checksum; + } + + public String getUrl() { + return url; + } + + public Optional<String> getChecksum() { + return Optional.ofNullable(checksum); } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index e465c8306e..77a13686c8 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -37,14 +37,20 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.NotFoundException; //~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; +import static sonia.scm.ContextEntry.ContextBuilder.entity; + /** * * @author Sebastian Sdorra @@ -52,13 +58,17 @@ import java.util.stream.Collectors; @Singleton public class DefaultPluginManager implements PluginManager { + private static final Logger LOG = LoggerFactory.getLogger(DefaultPluginManager.class); + private final PluginLoader loader; private final PluginCenter center; + private final PluginInstaller installer; @Inject - public DefaultPluginManager(PluginLoader loader, PluginCenter center) { + public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer) { this.loader = loader; this.center = center; + this.installer = installer; } @Override @@ -98,6 +108,18 @@ public class DefaultPluginManager implements PluginManager { @Override public void install(String name) { + if (getInstalled(name).isPresent()){ + LOG.info("plugin {} is already installed, skipping installation", name); + return; + } + AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); + Set<String> dependencies = plugin.getDescriptor().getDependencies(); + if (dependencies != null) { + for (String dependency: dependencies){ + install(dependency); + } + } + installer.install(plugin); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index afb8e739a0..1a18d696d5 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -3,6 +3,7 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -77,6 +78,8 @@ public final class PluginCenterDto implements Serializable { @XmlAccessorType(XmlAccessType.FIELD) @Getter + @NoArgsConstructor + @AllArgsConstructor static class Link { private String href; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java index 972afa3099..1b84bca147 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -17,8 +17,9 @@ public abstract class PluginCenterDtoMapper { Set<AvailablePlugin> map(PluginCenterDto pluginCenterDto) { Set<AvailablePlugin> plugins = new HashSet<>(); for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) { + String url = plugin.getLinks().get("download").getHref(); AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( - map(plugin), map(plugin.getConditions()), plugin.getDependencies() + map(plugin), map(plugin.getConditions()), plugin.getDependencies(), url, plugin.getSha256() ); plugins.add(new AvailablePlugin(descriptor)); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java new file mode 100644 index 0000000000..1b04c0adf0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java @@ -0,0 +1,7 @@ +package sonia.scm.plugin; + +public class PluginChecksumMismatchException extends PluginInstallException { + public PluginChecksumMismatchException(String message) { + super(message); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java new file mode 100644 index 0000000000..cb2a119f62 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java @@ -0,0 +1,7 @@ +package sonia.scm.plugin; + +public class PluginDownloadException extends PluginInstallException { + public PluginDownloadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java new file mode 100644 index 0000000000..d7a840bdc1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java @@ -0,0 +1,12 @@ +package sonia.scm.plugin; + +public class PluginInstallException extends RuntimeException { + + public PluginInstallException(String message) { + super(message); + } + + public PluginInstallException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java new file mode 100644 index 0000000000..83650a54a1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -0,0 +1,63 @@ +package sonia.scm.plugin; + +import com.google.common.base.Throwables; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteStreams; +import com.google.common.io.Files; +import sonia.scm.SCMContextProvider; +import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.util.IOUtil; + +import javax.inject.Inject; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Optional; + +class PluginInstaller { + + private final SCMContextProvider context; + private final AdvancedHttpClient client; + + @Inject + public PluginInstaller(SCMContextProvider context, AdvancedHttpClient client) { + this.context = context; + this.client = client; + } + + public void install(AvailablePlugin plugin) { + File file = createFile(plugin); + try (InputStream input = download(plugin); OutputStream output = new FileOutputStream(file)) { + ByteStreams.copy(input, output); + + verifyChecksum(plugin, file); + } catch (IOException ex) { + throw new PluginDownloadException("failed to install plugin", ex); + } + } + + private void verifyChecksum(AvailablePlugin plugin, File file) throws IOException { + Optional<String> checksum = plugin.getDescriptor().getChecksum(); + if (checksum.isPresent()) { + String calculatedChecksum = Files.hash(file, Hashing.sha256()).toString(); + if (!checksum.get().equalsIgnoreCase(calculatedChecksum)) { + throw new PluginChecksumMismatchException( + String.format("downloaded plugin checksum %s does not match expected %s", calculatedChecksum, checksum.get()) + ); + } + } + } + + private InputStream download(AvailablePlugin plugin) throws IOException { + return client.get(plugin.getDescriptor().getUrl()).request().contentAsStream(); + } + + private File createFile(AvailablePlugin plugin) { + File pluginDirectory = new File(context.getBaseDirectory(), "plugins"); + IOUtil.mkdirs(pluginDirectory); + return new File(pluginDirectory, plugin.getDescriptor().getInformation().getName() + ".smp"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 54954c19d7..87a29a3210 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -21,7 +21,6 @@ import sonia.scm.plugin.AvailablePluginDescriptor; import sonia.scm.plugin.PluginCondition; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; -import sonia.scm.plugin.PluginState; import sonia.scm.web.VndMediaType; import javax.inject.Provider; @@ -148,7 +147,9 @@ class AvailablePluginResourceTest { } private AvailablePlugin createPlugin(PluginInformation pluginInformation) { - AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor(pluginInformation, new PluginCondition(), Collections.emptySet()); + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( + pluginInformation, new PluginCondition(), Collections.emptySet(), "https://download.hitchhiker.com", null + ); return new AvailablePlugin(descriptor); } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index 22fe370915..c23f9394c3 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -2,7 +2,6 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import org.checkerframework.checker.nullness.Opt; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; @@ -10,16 +9,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Arrays; import java.util.List; import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class DefaultPluginManagerTest { @@ -30,6 +24,9 @@ class DefaultPluginManagerTest { @Mock private PluginCenter center; + @Mock + private PluginInstaller installer; + @InjectMocks private DefaultPluginManager manager; @@ -118,6 +115,44 @@ class DefaultPluginManagerTest { assertThat(available).isEmpty(); } + @Test + void shouldInstallThePlugin() { + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + + manager.install("scm-git-plugin"); + + verify(installer).install(git); + } + + @Test + void shouldInstallDependingPlugins() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + manager.install("scm-review-plugin"); + + verify(installer).install(mail); + verify(installer).install(review); + } + + @Test + void shouldNotInstallAlreadyInstalledDependencies() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); + + manager.install("scm-review-plugin"); + + verify(installer).install(review); + } + private AvailablePlugin createAvailable(String name) { PluginInformation information = new PluginInformation(); information.setName(name); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index 0c0b80ccfa..af4aac0e0d 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -1,5 +1,6 @@ package sonia.scm.plugin; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,13 +43,17 @@ class PluginCenterDtoMapperTest { "555000444", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), ImmutableSet.of("scm-review-plugin"), - new HashMap<>()); + ImmutableMap.of("download", new Link("http://download.hitchhiker.com")) + ); when(dto.getEmbedded().getPlugins()).thenReturn(Collections.singletonList(plugin)); AvailablePluginDescriptor descriptor = mapper.map(dto).iterator().next().getDescriptor(); PluginInformation information = descriptor.getInformation(); PluginCondition condition = descriptor.getCondition(); + assertThat(descriptor.getUrl()).isEqualTo("http://download.hitchhiker.com"); + assertThat(descriptor.getChecksum()).contains("555000444"); + assertThat(information.getAuthor()).isEqualTo(plugin.getAuthor()); assertThat(information.getCategory()).isEqualTo(plugin.getCategory()); assertThat(information.getVersion()).isEqualTo(plugin.getVersion()); @@ -72,7 +77,8 @@ class PluginCenterDtoMapperTest { "12345678aa", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), ImmutableSet.of("scm-review-plugin"), - new HashMap<>()); + ImmutableMap.of("download", new Link("http://download.hitchhiker.com/review")) + ); Plugin plugin2 = new Plugin( "scm-hitchhiker-plugin", @@ -85,7 +91,8 @@ class PluginCenterDtoMapperTest { "555000444", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), ImmutableSet.of("scm-review-plugin"), - new HashMap<>()); + ImmutableMap.of("download", new Link("http://download.hitchhiker.com/hitchhiker")) + ); when(dto.getEmbedded().getPlugins()).thenReturn(Arrays.asList(plugin1, plugin2)); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java new file mode 100644 index 0000000000..8801549130 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -0,0 +1,90 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.net.ahc.AdvancedHttpClient; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.in; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, TempDirectory.class}) +class PluginInstallerTest { + + @Mock + private SCMContextProvider context; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private AdvancedHttpClient client; + + @InjectMocks + private PluginInstaller installer; + + private Path directory; + + @BeforeEach + void setUpContext(@TempDirectory.TempDir Path directory) { + this.directory = directory; + when(context.getBaseDirectory()).thenReturn(directory.toFile()); + } + + @Test + void shouldDownloadPlugin() throws IOException { + mockContent("42"); + + installer.install(createGitPlugin()); + + assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).hasContent("42"); + } + + private void mockContent(String content) throws IOException { + when(client.get("https://download.hitchhiker.com").request().contentAsStream()) + .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + } + + private AvailablePlugin createGitPlugin() { + return createPlugin( + "scm-git-plugin", + "https://download.hitchhiker.com", + "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049" // 42 + ); + } + + @Test + void shouldThrowPluginDownloadException() throws IOException { + when(client.get("https://download.hitchhiker.com").request()).thenThrow(new IOException("failed to download")); + + assertThrows(PluginDownloadException.class, () -> installer.install(createGitPlugin())); + } + + @Test + void shouldThrowPluginChecksumMismatchException() throws IOException { + mockContent("21"); + + assertThrows(PluginChecksumMismatchException.class, () -> installer.install(createGitPlugin())); + } + + + private AvailablePlugin createPlugin(String name, String url, String checksum) { + PluginInformation information = new PluginInformation(); + information.setName(name); + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( + information, null, Collections.emptySet(), url, checksum + ); + return new AvailablePlugin(descriptor); + } +} From 3f183f9f6c2eb8d667717dfe357099e0814bab6e Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Tue, 20 Aug 2019 15:19:02 +0200 Subject: [PATCH 101/135] added key value for child in list --- .../packages/ui-components/src/buttons/ButtonGroup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js b/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js index 2dcb56047c..b73688ebbf 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js +++ b/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js @@ -14,7 +14,7 @@ class ButtonGroup extends React.Component<Props> { const childWrapper = []; React.Children.forEach(children, child => { if (child) { - childWrapper.push(<p className="control">{child}</p>); + childWrapper.push(<p className="control" key={childWrapper.length}>{child}</p>); } }); From 02c295207cb5dcb0daf8ba5a82d06f3aea3a2754 Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Tue, 20 Aug 2019 15:50:11 +0200 Subject: [PATCH 102/135] added modal with check for plugin installation --- .../packages/ui-types/src/Plugin.js | 2 +- scm-ui/public/locales/de/admin.json | 10 ++- scm-ui/public/locales/en/admin.json | 10 ++- .../admin/plugins/components/PluginEntry.js | 55 +++++++++---- .../admin/plugins/components/PluginModal.js | 78 +++++++++++++++++++ 5 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 scm-ui/src/admin/plugins/components/PluginModal.js diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 3f4f9858c1..157ef06098 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -1,7 +1,6 @@ //@flow import type {Collection, Links} from "./hal"; - export type Plugin = { name: string, version: string, @@ -10,6 +9,7 @@ export type Plugin = { author: string, category: string, avatarUrl: string, + dependencies: string[], _links: Links }; diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 54ae1ab91a..3f39de2717 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -29,7 +29,15 @@ "installedNavLink": "Installiert", "availableNavLink": "Verfügbar" }, - "noPlugins": "Keine Plugins gefunden." + "noPlugins": "Keine Plugins gefunden.", + "modal": { + "title": "Plugin installieren", + "dependency": "Abhängigkeit:", + "dependency_plural": "Abhängigkeiten:", + "restart": "Neustarten um Plugin zu aktivieren", + "install": "Installieren", + "abort": "Abbrechen" + } }, "repositoryRole": { "navLink": "Berechtigungsrollen", diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 2402f21423..970dc44c7f 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -29,7 +29,15 @@ "installedNavLink": "Installed", "availableNavLink": "Available" }, - "noPlugins": "No plugins found." + "noPlugins": "No plugins found.", + "modal": { + "title": "Install Plugin", + "dependency": "Dependency:", + "dependency_plural": "Dependencies:", + "restart": "Restart to activate", + "install": "Install", + "abort": "Abort" + } }, "repositoryRole": { "navLink": "Permission Roles", diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index 7aaeb3f67f..12bc5c35c5 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -1,9 +1,10 @@ //@flow import React from "react"; import injectSheet from "react-jss"; -import type {Plugin} from "@scm-manager/ui-types"; -import {CardColumn} from "@scm-manager/ui-components"; +import type { Plugin } from "@scm-manager/ui-types"; +import { CardColumn } from "@scm-manager/ui-components"; import PluginAvatar from "./PluginAvatar"; +import PluginModal from "./PluginModal"; type Props = { plugin: Plugin, @@ -12,23 +13,41 @@ type Props = { classes: any }; +type State = { + showModal: boolean +}; + const styles = { link: { - pointerEvents: "cursor" + pointerEvents: "all" } }; -class PluginEntry extends React.Component<Props> { +class PluginEntry extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + showModal: false + }; + } + createAvatar = (plugin: Plugin) => { return <PluginAvatar plugin={plugin} />; }; + toggleModal = () => { + this.setState(prevState => ({ + showModal: !prevState.showModal + })); + }; + createContentRight = (plugin: Plugin) => { const { classes } = this.props; if (plugin._links && plugin._links.install && plugin._links.install.href) { return ( - <div className={classes.link} onClick={() => console.log(plugin._links.install.href) /*TODO trigger plugin installation*/}> - <i className="fas fa-cloud-download-alt fa-2x has-text-info" /> + <div className={classes.link} onClick={this.toggleModal}> + <i className="fas fa-download fa-2x has-text-info" /> </div> ); } @@ -44,22 +63,28 @@ class PluginEntry extends React.Component<Props> { render() { const { plugin } = this.props; + const { showModal } = this.state; const avatar = this.createAvatar(plugin); const contentRight = this.createContentRight(plugin); const footerLeft = this.createFooterLeft(plugin); const footerRight = this.createFooterRight(plugin); + const modal = showModal ? <PluginModal plugin={plugin} onSubmit={this.toggleModal} onClose={this.toggleModal} /> : null; + // TODO: Add link to plugin page below return ( - <CardColumn - link="#" - avatar={avatar} - title={plugin.displayName ? plugin.displayName : plugin.name} - description={plugin.description} - contentRight={contentRight} - footerLeft={footerLeft} - footerRight={footerRight} - /> + <> + <CardColumn + link="#" + avatar={avatar} + title={plugin.displayName ? plugin.displayName : plugin.name} + description={plugin.description} + contentRight={contentRight} + footerLeft={footerLeft} + footerRight={footerRight} + /> + {modal} + </> ); } } diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js new file mode 100644 index 0000000000..aa126f6c58 --- /dev/null +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -0,0 +1,78 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import type { Plugin } from "@scm-manager/ui-types"; +import { + Button, + ButtonGroup, + Checkbox, + Modal, + SubmitButton +} from "@scm-manager/ui-components"; + +type Props = { + plugin: Plugin, + onSubmit: () => void, + onClose: () => void, + + // context props + t: string => string +}; + +class PluginModal extends React.Component<Props> { + renderDependencies() { + const { plugin, t } = this.props; + + let dependencies = null; + if (plugin.dependencies && plugin.dependencies.length > 0) { + dependencies = ( + <> + {t("plugins.modal.dependency", {count: plugin.dependencies.length})} + <ul> + {plugin.dependencies.map((dependency, index) => { + return <li key={index}>{dependency}</li>; + })} + </ul> + </> + ); + } + return dependencies; + } + + render() { + const { onSubmit, onClose, t } = this.props; + + const body = ( + <> + {this.renderDependencies()} + <Checkbox + checked={false} + label={t("plugins.modal.restart")} + onChange={null} + disabled={null} + /> + </> + ); + + const footer = ( + <form onSubmit={onSubmit}> + <ButtonGroup> + <SubmitButton label={t("plugins.modal.install")} /> + <Button label={t("plugins.modal.abort")} action={onClose} /> + </ButtonGroup> + </form> + ); + + return ( + <Modal + title={t("plugins.modal.title")} + closeFunction={() => onClose()} + body={body} + footer={footer} + active={true} + /> + ); + } +} + +export default translate("admin")(PluginModal); From 8db2bbb28da9823a85f4b4ae1ee030390d756697 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 20 Aug 2019 16:38:29 +0200 Subject: [PATCH 103/135] PluginInstaller returns now PendingPluginInstallation, to abort the installation before restart --- .../scm/plugin/PendingPluginInstallation.java | 31 +++++++++++++ ...inFailedToCancelInstallationException.java | 7 +++ .../sonia/scm/plugin/PluginInstaller.java | 8 ++-- .../plugin/PendingPluginInstallationTest.java | 46 +++++++++++++++++++ .../sonia/scm/plugin/PluginInstallerTest.java | 11 +++++ 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java new file mode 100644 index 0000000000..366558f3c9 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java @@ -0,0 +1,31 @@ +package sonia.scm.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; + +class PendingPluginInstallation { + + private static final Logger LOG = LoggerFactory.getLogger(PendingPluginInstallation.class); + + private final AvailablePlugin plugin; + private final File file; + + PendingPluginInstallation(AvailablePlugin plugin, File file) { + this.plugin = plugin; + this.file = file; + } + + public AvailablePlugin getPlugin() { + return plugin; + } + + void cancel() { + String name = plugin.getDescriptor().getInformation().getName(); + LOG.info("cancel installation of plugin {}", name); + if (!file.delete()) { + throw new PluginFailedToCancelInstallationException("failed to cancel installation of plugin " + name); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java new file mode 100644 index 0000000000..2bb6db8125 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java @@ -0,0 +1,7 @@ +package sonia.scm.plugin; + +public class PluginFailedToCancelInstallationException extends RuntimeException { + public PluginFailedToCancelInstallationException(String message) { + super(message); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index 83650a54a1..e512ea3212 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -1,7 +1,5 @@ package sonia.scm.plugin; -import com.google.common.base.Throwables; -import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import com.google.common.io.ByteStreams; import com.google.common.io.Files; @@ -28,12 +26,16 @@ class PluginInstaller { this.client = client; } - public void install(AvailablePlugin plugin) { + public PendingPluginInstallation install(AvailablePlugin plugin) { File file = createFile(plugin); try (InputStream input = download(plugin); OutputStream output = new FileOutputStream(file)) { ByteStreams.copy(input, output); verifyChecksum(plugin, file); + + // TODO clean up in case of error + + return new PendingPluginInstallation(plugin, file); } catch (IOException ex) { throw new PluginDownloadException("failed to install plugin", ex); } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java new file mode 100644 index 0000000000..0e4ff9cbce --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java @@ -0,0 +1,46 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, TempDirectory.class}) +class PendingPluginInstallationTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private AvailablePlugin plugin; + + @Test + void shouldDeleteFileOnCancel(@TempDirectory.TempDir Path directory) throws IOException { + Path file = directory.resolve("file"); + Files.write(file, "42".getBytes()); + + when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin"); + + PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file.toFile()); + installation.cancel(); + + assertThat(file).doesNotExist(); + } + + @Test + void shouldThrowExceptionIfCancelFailed(@TempDirectory.TempDir Path directory) { + Path file = directory.resolve("file"); + when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin"); + + PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file.toFile()); + assertThrows(PluginFailedToCancelInstallationException.class, installation::cancel); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index 8801549130..e209641222 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -51,6 +51,17 @@ class PluginInstallerTest { assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).hasContent("42"); } + @Test + void shouldReturnPendingPluginInstallation() throws IOException { + mockContent("42"); + AvailablePlugin gitPlugin = createGitPlugin(); + + PendingPluginInstallation pending = installer.install(gitPlugin); + + assertThat(pending).isNotNull(); + assertThat(pending.getPlugin()).isSameAs(gitPlugin); + } + private void mockContent(String content) throws IOException { when(client.get("https://download.hitchhiker.com").request().contentAsStream()) .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); From 230ac848ebd72ff476d925a7b03fbf0d4b447580 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Tue, 20 Aug 2019 16:39:50 +0200 Subject: [PATCH 104/135] ignore old plugins folder --- .../src/main/java/sonia/scm/plugin/PluginProcessor.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index b91ee9b1ee..0613f4ef07 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -60,6 +60,8 @@ import java.util.Date; import java.util.List; import java.util.Set; +import static java.util.stream.Collectors.toList; + //~--- JDK imports ------------------------------------------------------------ /** @@ -171,7 +173,11 @@ public final class PluginProcessor extract(archives); - List<Path> dirs = collectPluginDirectories(pluginDirectory); + List<Path> dirs = + collectPluginDirectories(pluginDirectory) + .stream() + .filter(dir -> !dir.endsWith("sonia.scm.plugins")) + .collect(toList()); logger.debug("process {} directories: {}", dirs.size(), dirs); From f72f740f17bbf35bd19aa221deabcaf5d1432bdc Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Tue, 20 Aug 2019 17:19:57 +0200 Subject: [PATCH 105/135] added more information to modal body --- scm-ui/public/locales/de/admin.json | 10 +- scm-ui/public/locales/en/admin.json | 14 +- .../admin/plugins/components/PluginEntry.js | 1 + .../admin/plugins/components/PluginModal.js | 127 +++++++++++++++--- 4 files changed, 125 insertions(+), 27 deletions(-) diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 3f39de2717..3b9a79dfa9 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -31,12 +31,14 @@ }, "noPlugins": "Keine Plugins gefunden.", "modal": { - "title": "Plugin installieren", - "dependency": "Abhängigkeit:", - "dependency_plural": "Abhängigkeiten:", + "title": "{{name}} Plugin installieren", "restart": "Neustarten um Plugin zu aktivieren", "install": "Installieren", - "abort": "Abbrechen" + "abort": "Abbrechen", + "author": "Autor", + "version": "Version", + "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installieren wenn sie noch nicht vorhanden sind.", + "dependencies": "Abhängigkeiten" } }, "repositoryRole": { diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 970dc44c7f..7a601fefe4 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -6,7 +6,7 @@ "settingsNavLink": "Settings", "generalNavLink": "General" }, - "info": { + "info": { "currentAppVersion": "Current Application Version", "communityTitle": "Community Support", "communityIconAlt": "Community Support Icon", @@ -31,12 +31,14 @@ }, "noPlugins": "No plugins found.", "modal": { - "title": "Install Plugin", - "dependency": "Dependency:", - "dependency_plural": "Dependencies:", + "title": "Install {{name}} Plugin", "restart": "Restart to activate", "install": "Install", - "abort": "Abort" + "abort": "Abort", + "author": "Author", + "version": "Version", + "dependencyNotification": "With this plugin, the following dependencies are installed if they are not available yet.", + "dependencies": "Dependencies" } }, "repositoryRole": { @@ -61,7 +63,7 @@ "permissions": "Permissions", "submit": "Save" }, - "delete" : { + "delete": { "button": "Löschen", "subtitle": "Berechtigungsrolle löschen", "confirmAlert": { diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index 12bc5c35c5..fa62786fc2 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -19,6 +19,7 @@ type State = { const styles = { link: { + cursor: "pointer", pointerEvents: "all" } }; diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index aa126f6c58..00475fb9fe 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -1,6 +1,8 @@ //@flow import React from "react"; +import { compose } from "redux"; import { translate } from "react-i18next"; +import injectSheet from "react-jss"; import type { Plugin } from "@scm-manager/ui-types"; import { Button, @@ -9,6 +11,7 @@ import { Modal, SubmitButton } from "@scm-manager/ui-components"; +import classNames from "classnames"; type Props = { plugin: Plugin, @@ -16,23 +19,58 @@ type Props = { onClose: () => void, // context props + classes: any, t: string => string }; +const styles = { + titleVersion: { + marginLeft: "0.75rem" + }, + userLabelAlignment: { + textAlign: "left", + marginRight: 0, + minWidth: "5.5em" + }, + userFieldFlex: { + flexGrow: 4 + }, + listSpacing: { + marginTop: "0 !important" + } +}; + class PluginModal extends React.Component<Props> { renderDependencies() { - const { plugin, t } = this.props; + const { plugin, classes, t } = this.props; let dependencies = null; if (plugin.dependencies && plugin.dependencies.length > 0) { dependencies = ( <> - {t("plugins.modal.dependency", {count: plugin.dependencies.length})} - <ul> - {plugin.dependencies.map((dependency, index) => { - return <li key={index}>{dependency}</li>; - })} - </ul> + <strong>{t("plugin.modal.dependencyNotification")}</strong> + <div className="field is-horizontal"> + <div + className={classNames( + classes.userLabelAlignment, + "field-label is-inline-flex" + )} + > + {t("plugins.modal.dependencies")}: + </div> + <div + className={classNames( + classes.userFieldFlex, + "field-body is-inline-flex" + )} + > + <ul className={classes.listSpacing}> + {plugin.dependencies.map((dependency, index) => { + return <li key={index}>{dependency}</li>; + })} + </ul> + </div> + </div> </> ); } @@ -40,17 +78,67 @@ class PluginModal extends React.Component<Props> { } render() { - const { onSubmit, onClose, t } = this.props; + const { plugin, onSubmit, onClose, classes, t } = this.props; const body = ( <> - {this.renderDependencies()} - <Checkbox - checked={false} - label={t("plugins.modal.restart")} - onChange={null} - disabled={null} - /> + <div className="media"> + <div className="media-content"> + <p>{plugin.description && plugin.description}</p> + </div> + </div> + <div className="media"> + <div className="media-content"> + <div className="field is-horizontal"> + <div + className={classNames( + classes.userLabelAlignment, + "field-label is-inline-flex" + )} + > + {t("plugins.modal.author")}: + </div> + <div + className={classNames( + classes.userFieldFlex, + "field-body is-inline-flex" + )} + > + {plugin.author} + </div> + </div> + <div className="field is-horizontal"> + <div + className={classNames( + classes.userLabelAlignment, + "field-label is-inline-flex" + )} + > + {t("plugins.modal.version")}: + </div> + <div + className={classNames( + classes.userFieldFlex, + "field-body is-inline-flex" + )} + > + {plugin.version} + </div> + </div> + + {this.renderDependencies()} + </div> + </div> + <div className="media"> + <div className="media-content"> + <Checkbox + checked={false} + label={t("plugins.modal.restart")} + onChange={null} + disabled={null} + /> + </div> + </div> </> ); @@ -65,7 +153,9 @@ class PluginModal extends React.Component<Props> { return ( <Modal - title={t("plugins.modal.title")} + title={t("plugins.modal.title", { + name: plugin.displayName ? plugin.displayName : "" + })} closeFunction={() => onClose()} body={body} footer={footer} @@ -75,4 +165,7 @@ class PluginModal extends React.Component<Props> { } } -export default translate("admin")(PluginModal); +export default compose( + injectSheet(styles), + translate("admin") +)(PluginModal); From 7e442450085d591f224d4017238c5867905aac7b Mon Sep 17 00:00:00 2001 From: Florian Scholdei <florian.scholdei@cloudogu.com> Date: Tue, 20 Aug 2019 17:23:03 +0200 Subject: [PATCH 106/135] added exclamation mark --- scm-ui/public/locales/de/admin.json | 2 +- scm-ui/public/locales/en/admin.json | 2 +- scm-ui/src/admin/plugins/components/PluginModal.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 3b9a79dfa9..df01dd8dbb 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -37,7 +37,7 @@ "abort": "Abbrechen", "author": "Autor", "version": "Version", - "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installieren wenn sie noch nicht vorhanden sind.", + "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installieren wenn sie noch nicht vorhanden sind!", "dependencies": "Abhängigkeiten" } }, diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 7a601fefe4..8c920c0df3 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -37,7 +37,7 @@ "abort": "Abort", "author": "Author", "version": "Version", - "dependencyNotification": "With this plugin, the following dependencies are installed if they are not available yet.", + "dependencyNotification": "With this plugin, the following dependencies are installed if they are not available yet!", "dependencies": "Dependencies" } }, diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index 00475fb9fe..cfa5a858e4 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -48,7 +48,7 @@ class PluginModal extends React.Component<Props> { if (plugin.dependencies && plugin.dependencies.length > 0) { dependencies = ( <> - <strong>{t("plugin.modal.dependencyNotification")}</strong> + <strong>{t("plugins.modal.dependencyNotification")}</strong> <div className="field is-horizontal"> <div className={classNames( From e0fa09fd04b822e89709611e469be934e9d312d7 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 07:44:50 +0200 Subject: [PATCH 107/135] improve hash calculation and use nio file apis --- .../scm/plugin/PendingPluginInstallation.java | 14 +++++--- ...inFailedToCancelInstallationException.java | 4 +-- .../sonia/scm/plugin/PluginInstaller.java | 34 +++++++++---------- .../plugin/PendingPluginInstallationTest.java | 4 +-- .../sonia/scm/plugin/PluginInstallerTest.java | 7 +++- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java index 366558f3c9..fa59930a78 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java @@ -3,16 +3,18 @@ package sonia.scm.plugin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; class PendingPluginInstallation { private static final Logger LOG = LoggerFactory.getLogger(PendingPluginInstallation.class); private final AvailablePlugin plugin; - private final File file; + private final Path file; - PendingPluginInstallation(AvailablePlugin plugin, File file) { + PendingPluginInstallation(AvailablePlugin plugin, Path file) { this.plugin = plugin; this.file = file; } @@ -24,8 +26,10 @@ class PendingPluginInstallation { void cancel() { String name = plugin.getDescriptor().getInformation().getName(); LOG.info("cancel installation of plugin {}", name); - if (!file.delete()) { - throw new PluginFailedToCancelInstallationException("failed to cancel installation of plugin " + name); + try { + Files.delete(file); + } catch (IOException ex) { + throw new PluginFailedToCancelInstallationException("failed to cancel installation of plugin " + name, ex); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java index 2bb6db8125..e3d6c123d6 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java @@ -1,7 +1,7 @@ package sonia.scm.plugin; public class PluginFailedToCancelInstallationException extends RuntimeException { - public PluginFailedToCancelInstallationException(String message) { - super(message); + public PluginFailedToCancelInstallationException(String message, Throwable cause) { + super(message, cause); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index e512ea3212..b001059ea0 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -1,20 +1,20 @@ package sonia.scm.plugin; +import com.google.common.hash.HashCode; import com.google.common.hash.Hashing; -import com.google.common.io.ByteStreams; -import com.google.common.io.Files; +import com.google.common.hash.HashingInputStream; import sonia.scm.SCMContextProvider; import sonia.scm.net.ahc.AdvancedHttpClient; -import sonia.scm.util.IOUtil; import javax.inject.Inject; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Optional; +@SuppressWarnings("UnstableApiUsage") // guava hash is marked as unstable class PluginInstaller { private final SCMContextProvider context; @@ -27,24 +27,24 @@ class PluginInstaller { } public PendingPluginInstallation install(AvailablePlugin plugin) { - File file = createFile(plugin); - try (InputStream input = download(plugin); OutputStream output = new FileOutputStream(file)) { - ByteStreams.copy(input, output); + try (HashingInputStream input = new HashingInputStream(Hashing.sha256(), download(plugin))) { + Path file = createFile(plugin); + Files.copy(input, file); - verifyChecksum(plugin, file); + verifyChecksum(plugin, input.hash()); // TODO clean up in case of error return new PendingPluginInstallation(plugin, file); } catch (IOException ex) { - throw new PluginDownloadException("failed to install plugin", ex); + throw new PluginDownloadException("failed to download plugin", ex); } } - private void verifyChecksum(AvailablePlugin plugin, File file) throws IOException { + private void verifyChecksum(AvailablePlugin plugin, HashCode hash) { Optional<String> checksum = plugin.getDescriptor().getChecksum(); if (checksum.isPresent()) { - String calculatedChecksum = Files.hash(file, Hashing.sha256()).toString(); + String calculatedChecksum = hash.toString(); if (!checksum.get().equalsIgnoreCase(calculatedChecksum)) { throw new PluginChecksumMismatchException( String.format("downloaded plugin checksum %s does not match expected %s", calculatedChecksum, checksum.get()) @@ -57,9 +57,9 @@ class PluginInstaller { return client.get(plugin.getDescriptor().getUrl()).request().contentAsStream(); } - private File createFile(AvailablePlugin plugin) { - File pluginDirectory = new File(context.getBaseDirectory(), "plugins"); - IOUtil.mkdirs(pluginDirectory); - return new File(pluginDirectory, plugin.getDescriptor().getInformation().getName() + ".smp"); + private Path createFile(AvailablePlugin plugin) throws IOException { + Path directory = context.resolve(Paths.get("plugins")); + Files.createDirectories(directory); + return directory.resolve(plugin.getDescriptor().getInformation().getName() + ".smp"); } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java index 0e4ff9cbce..ae61d6e367 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java @@ -28,7 +28,7 @@ class PendingPluginInstallationTest { when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin"); - PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file.toFile()); + PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file); installation.cancel(); assertThat(file).doesNotExist(); @@ -39,7 +39,7 @@ class PendingPluginInstallationTest { Path file = directory.resolve("file"); when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin"); - PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file.toFile()); + PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file); assertThrows(PluginFailedToCancelInstallationException.class, installation::cancel); } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index e209641222..0aa99c358c 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -20,6 +20,8 @@ import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.in; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith({MockitoExtension.class, TempDirectory.class}) @@ -39,7 +41,10 @@ class PluginInstallerTest { @BeforeEach void setUpContext(@TempDirectory.TempDir Path directory) { this.directory = directory; - when(context.getBaseDirectory()).thenReturn(directory.toFile()); + lenient().when(context.resolve(any())).then(ic -> { + Path arg = ic.getArgument(0); + return directory.resolve(arg); + }); } @Test From 7ef4b30027cb7d5e387bd51d3bdaf532c9b7303c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 07:56:52 +0200 Subject: [PATCH 108/135] remove downloaded artifact on error --- .../sonia/scm/plugin/PluginInstaller.java | 22 ++++++++++++++----- .../sonia/scm/plugin/PluginInstallerTest.java | 18 +++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index b001059ea0..71a4c35a3a 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -27,25 +27,35 @@ class PluginInstaller { } public PendingPluginInstallation install(AvailablePlugin plugin) { + Path file = null; try (HashingInputStream input = new HashingInputStream(Hashing.sha256(), download(plugin))) { - Path file = createFile(plugin); + file = createFile(plugin); Files.copy(input, file); - verifyChecksum(plugin, input.hash()); - - // TODO clean up in case of error - + verifyChecksum(plugin, input.hash(), file); return new PendingPluginInstallation(plugin, file); } catch (IOException ex) { + cleanup(file); throw new PluginDownloadException("failed to download plugin", ex); } } - private void verifyChecksum(AvailablePlugin plugin, HashCode hash) { + private void cleanup(Path file) { + try { + if (file != null) { + Files.deleteIfExists(file); + } + } catch (IOException e) { + throw new PluginInstallException("failed to cleanup, after broken installation"); + } + } + + private void verifyChecksum(AvailablePlugin plugin, HashCode hash, Path file) { Optional<String> checksum = plugin.getDescriptor().getChecksum(); if (checksum.isPresent()) { String calculatedChecksum = hash.toString(); if (!checksum.get().equalsIgnoreCase(calculatedChecksum)) { + cleanup(file); throw new PluginChecksumMismatchException( String.format("downloaded plugin checksum %s does not match expected %s", calculatedChecksum, checksum.get()) ); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index 0aa99c358c..4e2de333b9 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -13,16 +13,15 @@ import sonia.scm.net.ahc.AdvancedHttpClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.in; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith({MockitoExtension.class, TempDirectory.class}) class PluginInstallerTest { @@ -92,6 +91,17 @@ class PluginInstallerTest { mockContent("21"); assertThrows(PluginChecksumMismatchException.class, () -> installer.install(createGitPlugin())); + assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); + } + + @Test + void shouldThrowPluginDownloadExceptionAndCleanup() throws IOException { + InputStream stream = mock(InputStream.class); + when(stream.read(any(), anyInt(), anyInt())).thenThrow(new IOException("failed to read")); + when(client.get("https://download.hitchhiker.com").request().contentAsStream()).thenReturn(stream); + + assertThrows(PluginDownloadException.class, () -> installer.install(createGitPlugin())); + assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } From e6d8dad570ba63b105b48623a2bf45d8487b9295 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 21 Aug 2019 06:23:21 +0000 Subject: [PATCH 109/135] Close branch feature/remove_archive_flag From de7d18e02632e87a70a75574fd5adf46c4783289 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 08:42:57 +0200 Subject: [PATCH 110/135] cancel all pending installations, if a dependency failed to install --- .../scm/plugin/DefaultPluginManager.java | 47 ++++++++++++++----- .../scm/plugin/DefaultPluginManagerTest.java | 24 ++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 77a13686c8..d670a6609c 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -43,6 +43,7 @@ import sonia.scm.NotFoundException; //~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; @@ -99,7 +100,7 @@ public class DefaultPluginManager implements PluginManager { } private <T extends Plugin> Predicate<T> filterByName(String name) { - return (plugin) -> name.equals(plugin.getDescriptor().getInformation().getName()); + return plugin -> name.equals(plugin.getDescriptor().getInformation().getName()); } private boolean isNotInstalled(AvailablePlugin availablePlugin) { @@ -108,18 +109,42 @@ public class DefaultPluginManager implements PluginManager { @Override public void install(String name) { - if (getInstalled(name).isPresent()){ - LOG.info("plugin {} is already installed, skipping installation", name); - return; - } - AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); - Set<String> dependencies = plugin.getDescriptor().getDependencies(); - if (dependencies != null) { - for (String dependency: dependencies){ - install(dependency); + List<AvailablePlugin> plugins = collectPluginsToInstall(name); + List<PendingPluginInstallation> pendingInstallations = new ArrayList<>(); + for (AvailablePlugin plugin : plugins) { + try { + PendingPluginInstallation pending = installer.install(plugin); + pendingInstallations.add(pending); + } catch (PluginInstallException ex) { + cancelPending(pendingInstallations); + throw ex; } } + } - installer.install(plugin); + private void cancelPending(List<PendingPluginInstallation> pendingInstallations) { + pendingInstallations.forEach(PendingPluginInstallation::cancel); + } + + private List<AvailablePlugin> collectPluginsToInstall(String name) { + List<AvailablePlugin> plugins = new ArrayList<>(); + collectPluginsToInstall(plugins, name); + return plugins; + } + private void collectPluginsToInstall(List<AvailablePlugin> plugins, String name) { + if (!getInstalled(name).isPresent()) { + AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); + + Set<String> dependencies = plugin.getDescriptor().getDependencies(); + if (dependencies != null) { + for (String dependency: dependencies){ + collectPluginsToInstall(plugins, dependency); + } + } + + plugins.add(plugin); + } else { + LOG.info("plugin {} is already installed, skipping installation", name); + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index c23f9394c3..2ab111689a 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -153,6 +154,29 @@ class DefaultPluginManagerTest { verify(installer).install(review); } + @Test + void shouldRollbackOnFailedInstallation() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); + AvailablePlugin notification = createAvailable("scm-notification-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification)); + + PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class); + doReturn(pendingNotification).when(installer).install(notification); + + PendingPluginInstallation pendingMail = mock(PendingPluginInstallation.class); + doReturn(pendingMail).when(installer).install(mail); + + doThrow(new PluginChecksumMismatchException("checksum does not match")).when(installer).install(review); + + assertThrows(PluginInstallException.class, () -> manager.install("scm-review-plugin")); + + verify(pendingNotification).cancel(); + verify(pendingMail).cancel(); + } + private AvailablePlugin createAvailable(String name) { PluginInformation information = new PluginInformation(); information.setName(name); From 5694a953afef6242232214d2d59bcd4ca56087d6 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 09:25:44 +0200 Subject: [PATCH 111/135] implemented permission checks --- .../v2/resources/AvailablePluginResource.java | 2 +- .../scm/plugin/DefaultPluginManager.java | 6 + .../scm/plugin/DefaultPluginManagerTest.java | 347 +++++++++++------- 3 files changed, 222 insertions(+), 133 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 6e90106096..f8a8aa561f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -81,7 +81,7 @@ public class AvailablePluginResource { /** * Triggers plugin installation. - * @param name plugin artefact name + * @param name plugin name * @return HTTP Status. */ @POST diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index d670a6609c..0ba8636c66 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -74,6 +74,7 @@ public class DefaultPluginManager implements PluginManager { @Override public Optional<AvailablePlugin> getAvailable(String name) { + PluginPermissions.read().check(); return center.getAvailable() .stream() .filter(filterByName(name)) @@ -83,6 +84,7 @@ public class DefaultPluginManager implements PluginManager { @Override public Optional<InstalledPlugin> getInstalled(String name) { + PluginPermissions.read().check(); return loader.getInstalledPlugins() .stream() .filter(filterByName(name)) @@ -91,11 +93,13 @@ public class DefaultPluginManager implements PluginManager { @Override public List<InstalledPlugin> getInstalled() { + PluginPermissions.read().check(); return ImmutableList.copyOf(loader.getInstalledPlugins()); } @Override public List<AvailablePlugin> getAvailable() { + PluginPermissions.read().check(); return center.getAvailable().stream().filter(this::isNotInstalled).collect(Collectors.toList()); } @@ -109,6 +113,7 @@ public class DefaultPluginManager implements PluginManager { @Override public void install(String name) { + PluginPermissions.manage().check(); List<AvailablePlugin> plugins = collectPluginsToInstall(name); List<PendingPluginInstallation> pendingInstallations = new ArrayList<>(); for (AvailablePlugin plugin : plugins) { @@ -131,6 +136,7 @@ public class DefaultPluginManager implements PluginManager { collectPluginsToInstall(plugins, name); return plugins; } + private void collectPluginsToInstall(List<AvailablePlugin> plugins, String name) { if (!getInstalled(name).isPresent()) { AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index 2ab111689a..a58c721e86 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -2,12 +2,19 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +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.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.NotFoundException; import java.util.List; import java.util.Optional; @@ -31,150 +38,226 @@ class DefaultPluginManagerTest { @InjectMocks private DefaultPluginManager manager; - @Test - void shouldReturnInstalledPlugins() { - InstalledPlugin review = createInstalled("scm-review-plugin"); - InstalledPlugin git = createInstalled("scm-git-plugin"); + @Mock + private Subject subject; - when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); + @Nested + class WithAdminPermissions { + + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void clearThreadContext() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldReturnInstalledPlugins() { + InstalledPlugin review = createInstalled("scm-review-plugin"); + InstalledPlugin git = createInstalled("scm-git-plugin"); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); + + List<InstalledPlugin> installed = manager.getInstalled(); + assertThat(installed).containsOnly(review, git); + } + + @Test + void shouldReturnReviewPlugin() { + InstalledPlugin review = createInstalled("scm-review-plugin"); + InstalledPlugin git = createInstalled("scm-git-plugin"); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); + + Optional<InstalledPlugin> plugin = manager.getInstalled("scm-review-plugin"); + assertThat(plugin).contains(review); + } + + @Test + void shouldReturnEmptyForNonInstalledPlugin() { + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of()); + + Optional<InstalledPlugin> plugin = manager.getInstalled("scm-review-plugin"); + assertThat(plugin).isEmpty(); + } + + @Test + void shouldReturnAvailablePlugins() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin"); + + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + List<AvailablePlugin> available = manager.getAvailable(); + assertThat(available).containsOnly(review, git); + } + + @Test + void shouldFilterOutAllInstalled() { + InstalledPlugin installedGit = createInstalled("scm-git-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); + + AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + List<AvailablePlugin> available = manager.getAvailable(); + assertThat(available).containsOnly(review); + } + + @Test + void shouldReturnAvailable() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); + assertThat(available).contains(git); + } + + @Test + void shouldReturnEmptyForNonExistingAvailable() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); + assertThat(available).isEmpty(); + } + + @Test + void shouldReturnEmptyForInstalledPlugin() { + InstalledPlugin installedGit = createInstalled("scm-git-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); + + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); + assertThat(available).isEmpty(); + } + + @Test + void shouldInstallThePlugin() { + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + + manager.install("scm-git-plugin"); + + verify(installer).install(git); + } + + @Test + void shouldInstallDependingPlugins() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + manager.install("scm-review-plugin"); + + verify(installer).install(mail); + verify(installer).install(review); + } + + @Test + void shouldNotInstallAlreadyInstalledDependencies() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); + + manager.install("scm-review-plugin"); + + verify(installer).install(review); + } + + @Test + void shouldRollbackOnFailedInstallation() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); + AvailablePlugin notification = createAvailable("scm-notification-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification)); + + PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class); + doReturn(pendingNotification).when(installer).install(notification); + + PendingPluginInstallation pendingMail = mock(PendingPluginInstallation.class); + doReturn(pendingMail).when(installer).install(mail); + + doThrow(new PluginChecksumMismatchException("checksum does not match")).when(installer).install(review); + + assertThrows(PluginInstallException.class, () -> manager.install("scm-review-plugin")); + + verify(pendingNotification).cancel(); + verify(pendingMail).cancel(); + } + + @Test + void shouldInstallNothingIfOneOfTheDependenciesIsNotAvailable() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin")); + + verify(installer, never()).install(any()); + } - List<InstalledPlugin> installed = manager.getInstalled(); - assertThat(installed).containsOnly(review, git); } - @Test - void shouldReturnReviewPlugin() { - InstalledPlugin review = createInstalled("scm-review-plugin"); - InstalledPlugin git = createInstalled("scm-git-plugin"); + @Nested + class WithoutReadPermissions { - when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + doThrow(AuthorizationException.class).when(subject).checkPermission("plugin:read"); + } + + @AfterEach + void clearThreadContext() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldThrowAuthorizationExceptionsForReadMethods() { + assertThrows(AuthorizationException.class, () -> manager.getInstalled()); + assertThrows(AuthorizationException.class, () -> manager.getInstalled("test")); + assertThrows(AuthorizationException.class, () -> manager.getAvailable()); + assertThrows(AuthorizationException.class, () -> manager.getAvailable("test")); + } - Optional<InstalledPlugin> plugin = manager.getInstalled("scm-review-plugin"); - assertThat(plugin).contains(review); } - @Test - void shouldReturnEmptyForNonInstalledPlugin() { - when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of()); + @Nested + class WithoutManagePermissions { - Optional<InstalledPlugin> plugin = manager.getInstalled("scm-review-plugin"); - assertThat(plugin).isEmpty(); - } + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + doThrow(AuthorizationException.class).when(subject).checkPermission("plugin:manage"); + } - @Test - void shouldReturnAvailablePlugins() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - AvailablePlugin git = createAvailable("scm-git-plugin"); + @AfterEach + void clearThreadContext() { + ThreadContext.unbindSubject(); + } - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + @Test + void shouldThrowAuthorizationExceptionsForInstallMethod() { + assertThrows(AuthorizationException.class, () -> manager.install("test")); + } - List<AvailablePlugin> available = manager.getAvailable(); - assertThat(available).containsOnly(review, git); - } - - @Test - void shouldFilterOutAllInstalled() { - InstalledPlugin installedGit = createInstalled("scm-git-plugin"); - when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); - - AvailablePlugin review = createAvailable("scm-review-plugin"); - AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); - - List<AvailablePlugin> available = manager.getAvailable(); - assertThat(available).containsOnly(review); - } - - @Test - void shouldReturnAvailable() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); - - Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); - assertThat(available).contains(git); - } - - @Test - void shouldReturnEmptyForNonExistingAvailable() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); - - Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); - assertThat(available).isEmpty(); - } - - @Test - void shouldReturnEmptyForInstalledPlugin() { - InstalledPlugin installedGit = createInstalled("scm-git-plugin"); - when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); - - AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); - - Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin"); - assertThat(available).isEmpty(); - } - - @Test - void shouldInstallThePlugin() { - AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); - - manager.install("scm-git-plugin"); - - verify(installer).install(git); - } - - @Test - void shouldInstallDependingPlugins() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); - - manager.install("scm-review-plugin"); - - verify(installer).install(mail); - verify(installer).install(review); - } - - @Test - void shouldNotInstallAlreadyInstalledDependencies() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); - - InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); - when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); - - manager.install("scm-review-plugin"); - - verify(installer).install(review); - } - - @Test - void shouldRollbackOnFailedInstallation() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); - when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); - AvailablePlugin notification = createAvailable("scm-notification-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification)); - - PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class); - doReturn(pendingNotification).when(installer).install(notification); - - PendingPluginInstallation pendingMail = mock(PendingPluginInstallation.class); - doReturn(pendingMail).when(installer).install(mail); - - doThrow(new PluginChecksumMismatchException("checksum does not match")).when(installer).install(review); - - assertThrows(PluginInstallException.class, () -> manager.install("scm-review-plugin")); - - verify(pendingNotification).cancel(); - verify(pendingMail).cancel(); } private AvailablePlugin createAvailable(String name) { From b9fbd2f28c5ff678066da693287ac7e42b4c8d12 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 21 Aug 2019 07:41:14 +0000 Subject: [PATCH 112/135] Close branch bugfix/diff_view From 05d7e0bd1ed662c123203c883b605416eb2d4b30 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 09:59:28 +0200 Subject: [PATCH 113/135] implemented plugin installation ui --- .../admin/plugins/components/PluginModal.js | 89 +++++++++++++++---- .../v2/resources/AvailablePluginResource.java | 1 - .../AvailablePluginResourceTest.java | 1 - 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index cfa5a858e4..f09f252cc5 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -5,9 +5,10 @@ import { translate } from "react-i18next"; import injectSheet from "react-jss"; import type { Plugin } from "@scm-manager/ui-types"; import { + apiClient, Button, ButtonGroup, - Checkbox, + Checkbox, ErrorNotification, Modal, SubmitButton } from "@scm-manager/ui-components"; @@ -20,7 +21,12 @@ type Props = { // context props classes: any, - t: string => string + t: (key: string, params?: Object) => string +}; + +type State = { + loading: boolean, + error?: Error }; const styles = { @@ -37,10 +43,55 @@ const styles = { }, listSpacing: { marginTop: "0 !important" + }, + error: { + marginTop: "1em" } }; -class PluginModal extends React.Component<Props> { +class PluginModal extends React.Component<Props,State> { + + constructor(props: Props) { + super(props); + this.state = { + loading: false + }; + } + + install = (e: Event) => { + const { plugin, onClose } = this.props; + this.setState({ + loading: true + }); + e.preventDefault(); + apiClient.post(plugin._links.install.href) + .then(() => { + this.setState({ + loading: false, + error: undefined + }, onClose); + }) + .catch(error => { + this.setState({ + loading: false, + error: error + }); + }); + }; + + footer = () => { + const { onClose, t } = this.props; + const { loading, error } = this.state; + return ( + <form> + <ButtonGroup> + <SubmitButton label={t("plugins.modal.install")} loading={loading} action={this.install} disabled={!!error} /> + <Button label={t("plugins.modal.abort")} action={onClose} /> + </ButtonGroup> + </form> + ); + }; + renderDependencies() { const { plugin, classes, t } = this.props; @@ -77,14 +128,28 @@ class PluginModal extends React.Component<Props> { return dependencies; } + renderError = () => { + const { classes } = this.props; + const { error } = this.state; + if (error) { + return ( + <div className={classes.error}> + <ErrorNotification error={error} /> + </div> + ); + } + return null; + }; + render() { - const { plugin, onSubmit, onClose, classes, t } = this.props; + + const { plugin, onClose, classes, t } = this.props; const body = ( <> <div className="media"> <div className="media-content"> - <p>{plugin.description && plugin.description}</p> + <p>{plugin.description}</p> </div> </div> <div className="media"> @@ -139,26 +204,18 @@ class PluginModal extends React.Component<Props> { /> </div> </div> + {this.renderError()} </> ); - const footer = ( - <form onSubmit={onSubmit}> - <ButtonGroup> - <SubmitButton label={t("plugins.modal.install")} /> - <Button label={t("plugins.modal.abort")} action={onClose} /> - </ButtonGroup> - </form> - ); - return ( <Modal title={t("plugins.modal.title", { - name: plugin.displayName ? plugin.displayName : "" + name: plugin.displayName ? plugin.displayName : plugin.name })} closeFunction={() => onClose()} body={body} - footer={footer} + footer={this.footer()} active={true} /> ); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index f8a8aa561f..0283b4a566 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -86,7 +86,6 @@ public class AvailablePluginResource { */ @POST @Path("/{name}/install") - @Consumes(VndMediaType.PLUGIN) @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 87a29a3210..bbeeeaf667 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -132,7 +132,6 @@ class AvailablePluginResourceTest { @Test void installPlugin() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/install"); - request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); From 72fe69b2d55ef8b177344404026719cabb6482d7 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 21 Aug 2019 10:34:40 +0200 Subject: [PATCH 114/135] add new IllegalIdentifierChangeException --- .../scm/IllegalIdentifierChangeException.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 scm-core/src/main/java/sonia/scm/IllegalIdentifierChangeException.java diff --git a/scm-core/src/main/java/sonia/scm/IllegalIdentifierChangeException.java b/scm-core/src/main/java/sonia/scm/IllegalIdentifierChangeException.java new file mode 100644 index 0000000000..63b9d0d4fc --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/IllegalIdentifierChangeException.java @@ -0,0 +1,21 @@ +package sonia.scm; + +import java.util.Collections; + +public class IllegalIdentifierChangeException extends BadRequestException { + + private static final String CODE = "thbsUFokjk"; + + public IllegalIdentifierChangeException(ContextEntry.ContextBuilder context, String message) { + super(context.build(), message); + } + + public IllegalIdentifierChangeException(String message) { + super(Collections.emptyList(), message); + } + + @Override + public String getCode() { + return CODE; + } +} From 7a29bba3398c52cd0ed0947edb1ef185e78a6d54 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Wed, 21 Aug 2019 10:36:48 +0200 Subject: [PATCH 115/135] use new IllegalIdentifierChangeException in SingleResourceManagerAdapter --- .../scm/api/v2/resources/SingleResourceManagerAdapter.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java index a7b9146d00..76371a0b54 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java @@ -2,6 +2,7 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; import sonia.scm.ConcurrentModificationException; +import sonia.scm.IllegalIdentifierChangeException; import sonia.scm.Manager; import sonia.scm.ModelObject; import sonia.scm.NotFoundException; @@ -11,8 +12,6 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; -import static javax.ws.rs.core.Response.Status.BAD_REQUEST; - /** * Adapter from resource http endpoints to managers, for Single resources (e.g. {@code /user/name}). * @@ -55,7 +54,7 @@ class SingleResourceManagerAdapter<MODEL_OBJECT extends ModelObject, MODEL_OBJECT existingModelObject = reader.get(); MODEL_OBJECT changedModelObject = applyChanges.apply(existingModelObject); if (!hasSameKey.test(changedModelObject)) { - return Response.status(BAD_REQUEST).entity("illegal change of id").build(); + throw new IllegalIdentifierChangeException("illegal change of id"); } else if (modelObjectWasModifiedConcurrently(existingModelObject, changedModelObject)) { throw new ConcurrentModificationException(type, keyExtractor.apply(existingModelObject)); From 25cb0d6a258b84008ad6a94b05f3ce9e2fe7c662 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 11:22:49 +0200 Subject: [PATCH 116/135] implemented restart after installation --- .../java/sonia/scm/plugin/PluginManager.java | 3 +- scm-ui/public/locales/de/admin.json | 6 +- scm-ui/public/locales/en/admin.json | 6 +- .../admin/plugins/components/PluginModal.js | 158 +++++++++++------- .../v2/resources/AvailablePluginResource.java | 5 +- .../scm/plugin/DefaultPluginManager.java | 11 +- .../AvailablePluginResourceTest.java | 2 +- .../scm/plugin/DefaultPluginManagerTest.java | 29 +++- 8 files changed, 143 insertions(+), 77 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index ad9045544c..235e360547 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -77,6 +77,7 @@ public interface PluginManager { * Installs the plugin with the given name from the list of available plugins. * * @param name plugin name + * @param restartAfterInstallation restart context after plugin installation */ - void install(String name); + void install(String name, boolean restartAfterInstallation); } diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index df01dd8dbb..19e8a07751 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -34,11 +34,15 @@ "title": "{{name}} Plugin installieren", "restart": "Neustarten um Plugin zu aktivieren", "install": "Installieren", + "installAndRestart": "Installieren und Neustarten", "abort": "Abbrechen", "author": "Autor", "version": "Version", "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installieren wenn sie noch nicht vorhanden sind!", - "dependencies": "Abhängigkeiten" + "dependencies": "Abhängigkeiten", + "successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:", + "reload": "jetzt new laden", + "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet." } }, "repositoryRole": { diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 8c920c0df3..afdc75585a 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -34,11 +34,15 @@ "title": "Install {{name}} Plugin", "restart": "Restart to activate", "install": "Install", + "installAndRestart": "Install and Restart", "abort": "Abort", "author": "Author", "version": "Version", "dependencyNotification": "With this plugin, the following dependencies are installed if they are not available yet!", - "dependencies": "Dependencies" + "dependencies": "Dependencies", + "successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:", + "reload": "reload now", + "restartNotification": "Restarting the scm-manager context, should only be done if no one else is currently working with it." } }, "repositoryRole": { diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index f09f252cc5..78af2d8bec 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -8,9 +8,10 @@ import { apiClient, Button, ButtonGroup, - Checkbox, ErrorNotification, + Checkbox, + ErrorNotification, Modal, - SubmitButton + Notification } from "@scm-manager/ui-components"; import classNames from "classnames"; @@ -25,14 +26,13 @@ type Props = { }; type State = { + success: boolean, + restart: boolean, loading: boolean, error?: Error }; const styles = { - titleVersion: { - marginLeft: "0.75rem" - }, userLabelAlignment: { textAlign: "left", marginRight: 0, @@ -40,37 +40,48 @@ const styles = { }, userFieldFlex: { flexGrow: 4 - }, - listSpacing: { - marginTop: "0 !important" - }, - error: { - marginTop: "1em" } }; -class PluginModal extends React.Component<Props,State> { - +class PluginModal extends React.Component<Props, State> { constructor(props: Props) { super(props); this.state = { - loading: false + loading: false, + restart: false, + success: false }; } + onInstallSuccess = () => { + const { restart } = this.state; + const { onClose } = this.props; + + const newState = { + loading: false, + error: undefined + }; + + if (restart) { + this.setState({ + ...newState, + success: true + }); + } else { + this.setState(newState, onClose); + } + }; + install = (e: Event) => { - const { plugin, onClose } = this.props; + const { restart } = this.state; + const { plugin } = this.props; this.setState({ loading: true }); e.preventDefault(); - apiClient.post(plugin._links.install.href) - .then(() => { - this.setState({ - loading: false, - error: undefined - }, onClose); - }) + apiClient + .post(plugin._links.install.href + "?restart=" + restart.toString()) + .then(this.onInstallSuccess) .catch(error => { this.setState({ loading: false, @@ -81,14 +92,25 @@ class PluginModal extends React.Component<Props,State> { footer = () => { const { onClose, t } = this.props; - const { loading, error } = this.state; + const { loading, error, restart, success } = this.state; + + let color = "primary"; + let label = "plugins.modal.install"; + if (restart) { + color = "warning"; + label = "plugins.modal.installAndRestart"; + } return ( - <form> - <ButtonGroup> - <SubmitButton label={t("plugins.modal.install")} loading={loading} action={this.install} disabled={!!error} /> - <Button label={t("plugins.modal.abort")} action={onClose} /> - </ButtonGroup> - </form> + <ButtonGroup> + <Button + label={t(label)} + color={color} + action={this.install} + loading={loading} + disabled={!!error || success} + /> + <Button label={t("plugins.modal.abort")} action={onClose} /> + </ButtonGroup> ); }; @@ -98,51 +120,61 @@ class PluginModal extends React.Component<Props,State> { let dependencies = null; if (plugin.dependencies && plugin.dependencies.length > 0) { dependencies = ( - <> - <strong>{t("plugins.modal.dependencyNotification")}</strong> - <div className="field is-horizontal"> - <div - className={classNames( - classes.userLabelAlignment, - "field-label is-inline-flex" - )} - > - {t("plugins.modal.dependencies")}: - </div> - <div - className={classNames( - classes.userFieldFlex, - "field-body is-inline-flex" - )} - > - <ul className={classes.listSpacing}> - {plugin.dependencies.map((dependency, index) => { - return <li key={index}>{dependency}</li>; - })} - </ul> - </div> - </div> - </> + <div className="media"> + <Notification type="warning"> + <strong>{t("plugins.modal.dependencyNotification")}</strong> + <ul className={classes.listSpacing}> + {plugin.dependencies.map((dependency, index) => { + return <li key={index}>{dependency}</li>; + })} + </ul> + </Notification> + </div> ); } return dependencies; } - renderError = () => { - const { classes } = this.props; - const { error } = this.state; + renderNotifications = () => { + const { t } = this.props; + const { restart, error, success } = this.state; if (error) { return ( - <div className={classes.error}> + <div className="media"> <ErrorNotification error={error} /> </div> ); + } else if (success) { + return ( + <div className="media"> + <Notification type="success"> + {t("plugins.modal.successNotification")}{" "} + <a onClick={e => window.location.reload()}> + {t("plugins.modal.reload")} + </a> + </Notification> + </div> + ); + } else if (restart) { + return ( + <div className="media"> + <Notification type="warning"> + {t("plugins.modal.restartNotification")} + </Notification> + </div> + ); } return null; }; - render() { + handleRestartChange = (value: boolean) => { + this.setState({ + restart: value + }); + }; + render() { + const { restart } = this.state; const { plugin, onClose, classes, t } = this.props; const body = ( @@ -197,14 +229,14 @@ class PluginModal extends React.Component<Props,State> { <div className="media"> <div className="media-content"> <Checkbox - checked={false} + checked={restart} label={t("plugins.modal.restart")} - onChange={null} - disabled={null} + onChange={this.handleRestartChange} + disabled={false} /> </div> </div> - {this.renderError()} + {this.renderNotifications()} </> ); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 0283b4a566..e6c4ef8410 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -16,6 +16,7 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import java.util.List; import java.util.Optional; @@ -90,9 +91,9 @@ public class AvailablePluginResource { @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") }) - public Response installPlugin(@PathParam("name") String name) { + public Response installPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) { PluginPermissions.manage().check(); - pluginManager.install(name); + pluginManager.install(name, restartAfterInstallation); return Response.ok().build(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 0ba8636c66..756f1fb741 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -40,6 +40,8 @@ import com.google.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; +import sonia.scm.event.ScmEventBus; +import sonia.scm.lifecycle.RestartEvent; //~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; @@ -61,12 +63,14 @@ public class DefaultPluginManager implements PluginManager { private static final Logger LOG = LoggerFactory.getLogger(DefaultPluginManager.class); + private final ScmEventBus eventBus; private final PluginLoader loader; private final PluginCenter center; private final PluginInstaller installer; @Inject - public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer) { + public DefaultPluginManager(ScmEventBus eventBus, PluginLoader loader, PluginCenter center, PluginInstaller installer) { + this.eventBus = eventBus; this.loader = loader; this.center = center; this.installer = installer; @@ -112,7 +116,7 @@ public class DefaultPluginManager implements PluginManager { } @Override - public void install(String name) { + public void install(String name, boolean restartAfterInstallation) { PluginPermissions.manage().check(); List<AvailablePlugin> plugins = collectPluginsToInstall(name); List<PendingPluginInstallation> pendingInstallations = new ArrayList<>(); @@ -125,6 +129,9 @@ public class DefaultPluginManager implements PluginManager { throw ex; } } + if (restartAfterInstallation) { + eventBus.post(new RestartEvent(PluginManager.class, "plugin installation")); + } } private void cancelPending(List<PendingPluginInstallation> pendingInstallations) { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index bbeeeaf667..2a473ea63e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -136,7 +136,7 @@ class AvailablePluginResourceTest { dispatcher.invoke(request, response); - verify(pluginManager).install("pluginName"); + verify(pluginManager).install("pluginName", false); assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index a58c721e86..e9469c10d6 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -15,6 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; +import sonia.scm.event.ScmEventBus; +import sonia.scm.lifecycle.RestartEvent; import java.util.List; import java.util.Optional; @@ -26,6 +28,9 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class DefaultPluginManagerTest { + @Mock + private ScmEventBus eventBus; + @Mock private PluginLoader loader; @@ -144,9 +149,10 @@ class DefaultPluginManagerTest { AvailablePlugin git = createAvailable("scm-git-plugin"); when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); - manager.install("scm-git-plugin"); + manager.install("scm-git-plugin", false); verify(installer).install(git); + verify(eventBus, never()).post(any()); } @Test @@ -156,7 +162,7 @@ class DefaultPluginManagerTest { AvailablePlugin mail = createAvailable("scm-mail-plugin"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); - manager.install("scm-review-plugin"); + manager.install("scm-review-plugin", false); verify(installer).install(mail); verify(installer).install(review); @@ -172,7 +178,7 @@ class DefaultPluginManagerTest { InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); - manager.install("scm-review-plugin"); + manager.install("scm-review-plugin", false); verify(installer).install(review); } @@ -194,7 +200,7 @@ class DefaultPluginManagerTest { doThrow(new PluginChecksumMismatchException("checksum does not match")).when(installer).install(review); - assertThrows(PluginInstallException.class, () -> manager.install("scm-review-plugin")); + assertThrows(PluginInstallException.class, () -> manager.install("scm-review-plugin", false)); verify(pendingNotification).cancel(); verify(pendingMail).cancel(); @@ -208,11 +214,22 @@ class DefaultPluginManagerTest { when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); - assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin")); + assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin", false)); verify(installer, never()).install(any()); } + @Test + void shouldSendRestartEventAfterInstallation() { + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + + manager.install("scm-git-plugin", true); + + verify(installer).install(git); + verify(eventBus).post(any(RestartEvent.class)); + } + } @Nested @@ -255,7 +272,7 @@ class DefaultPluginManagerTest { @Test void shouldThrowAuthorizationExceptionsForInstallMethod() { - assertThrows(AuthorizationException.class, () -> manager.install("test")); + assertThrows(AuthorizationException.class, () -> manager.install("test", false)); } } From 9514a94492f1e9203419efd14c4121b93adfdacf Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 12:49:15 +0200 Subject: [PATCH 117/135] handle pending plugin installations --- .../sonia/scm/plugin/AvailablePlugin.java | 17 ++++ .../main/java/sonia/scm/plugin/Plugin.java | 8 ++ .../java/sonia/scm/plugin/PluginManager.java | 5 ++ .../sonia/scm/plugin/AvailablePluginTest.java | 32 +++++++ .../v2/resources/AvailablePluginResource.java | 3 +- .../v2/resources/InstalledPluginResource.java | 2 +- .../sonia/scm/api/v2/resources/PluginDto.java | 2 + .../resources/PluginDtoCollectionMapper.java | 6 +- .../scm/api/v2/resources/PluginDtoMapper.java | 54 ++++++++---- .../scm/api/v2/resources/ResourceLinks.java | 9 +- .../scm/plugin/DefaultPluginManager.java | 45 ++++++++-- .../sonia/scm/plugin/PluginInstaller.java | 2 +- .../AvailablePluginResourceTest.java | 2 +- .../InstalledPluginResourceTest.java | 2 +- .../api/v2/resources/PluginDtoMapperTest.java | 80 ++++++++++++----- .../scm/plugin/DefaultPluginManagerTest.java | 86 ++++++++++++++++++- .../sonia/scm/plugin/PluginInstallerTest.java | 3 +- 17 files changed, 292 insertions(+), 66 deletions(-) create mode 100644 scm-core/src/test/java/sonia/scm/plugin/AvailablePluginTest.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java b/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java index 6596fa4751..2353f5a10e 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java @@ -1,11 +1,19 @@ package sonia.scm.plugin; +import com.google.common.base.Preconditions; + public class AvailablePlugin implements Plugin { private final AvailablePluginDescriptor pluginDescriptor; + private final boolean pending; public AvailablePlugin(AvailablePluginDescriptor pluginDescriptor) { + this(pluginDescriptor, false); + } + + private AvailablePlugin(AvailablePluginDescriptor pluginDescriptor, boolean pending) { this.pluginDescriptor = pluginDescriptor; + this.pending = pending; } @Override @@ -17,4 +25,13 @@ public class AvailablePlugin implements Plugin { public PluginState getState() { return PluginState.AVAILABLE; } + + public boolean isPending() { + return pending; + } + + public AvailablePlugin install() { + Preconditions.checkState(!pending, "installation is already pending"); + return new AvailablePlugin(pluginDescriptor, true); + } } diff --git a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java b/scm-core/src/main/java/sonia/scm/plugin/Plugin.java index e39d23c046..40f5ec2b49 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/Plugin.java @@ -3,6 +3,14 @@ package sonia.scm.plugin; public interface Plugin { PluginDescriptor getDescriptor(); + + /** + * Returns plugin state. + * + * @deprecated State is now derived from concrete plugin implementations + * @return plugin state + */ + @Deprecated PluginState getState(); } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index 235e360547..b7b8f69519 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -80,4 +80,9 @@ public interface PluginManager { * @param restartAfterInstallation restart context after plugin installation */ void install(String name, boolean restartAfterInstallation); + + /** + * Install all pending plugins and restart the scm context. + */ + void installPendingAndRestart(); } diff --git a/scm-core/src/test/java/sonia/scm/plugin/AvailablePluginTest.java b/scm-core/src/test/java/sonia/scm/plugin/AvailablePluginTest.java new file mode 100644 index 0000000000..bfdf74fdb1 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/plugin/AvailablePluginTest.java @@ -0,0 +1,32 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(MockitoExtension.class) +class AvailablePluginTest { + + @Mock + private AvailablePluginDescriptor descriptor; + + @Test + void shouldReturnNewPendingPluginOnInstall() { + AvailablePlugin plugin = new AvailablePlugin(descriptor); + assertThat(plugin.isPending()).isFalse(); + + AvailablePlugin installed = plugin.install(); + assertThat(installed.isPending()).isTrue(); + } + + @Test + void shouldThrowIllegalStateExceptionIfAlreadyPending() { + AvailablePlugin plugin = new AvailablePlugin(descriptor).install(); + assertThrows(IllegalStateException.class, () -> plugin.install()); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index e6c4ef8410..e54c6cf85e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -10,7 +10,6 @@ import sonia.scm.plugin.PluginPermissions; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -74,7 +73,7 @@ public class AvailablePluginResource { PluginPermissions.read().check(); Optional<AvailablePlugin> plugin = pluginManager.getAvailable(name); if (plugin.isPresent()) { - return Response.ok(mapper.map(plugin.get())).build(); + return Response.ok(mapper.mapAvailable(plugin.get())).build(); } else { throw notFound(entity(InstalledPluginDescriptor.class, name)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index 7c3f972a7b..893d09a51a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -73,7 +73,7 @@ public class InstalledPluginResource { PluginPermissions.read().check(); Optional<InstalledPlugin> pluginDto = pluginManager.getInstalled(name); if (pluginDto.isPresent()) { - return Response.ok(mapper.map(pluginDto.get())).build(); + return Response.ok(mapper.mapInstalled(pluginDto.get())).build(); } else { throw notFound(entity(InstalledPluginDescriptor.class, name)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index 07ccb3203e..bf20d1b67e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -11,6 +11,7 @@ import java.util.Set; @Getter @Setter @NoArgsConstructor +@SuppressWarnings("squid:S2160") // we do not need equals for dto public class PluginDto extends HalRepresentation { private String name; @@ -20,6 +21,7 @@ public class PluginDto extends HalRepresentation { private String author; private String category; private String avatarUrl; + private boolean pending; private Set<String> dependencies; public PluginDto(Links links) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index 276eddfab6..bcfbce3f04 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -5,10 +5,8 @@ import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import sonia.scm.plugin.AvailablePlugin; -import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.InstalledPlugin; -import java.util.Collection; import java.util.List; import static de.otto.edison.hal.Embedded.embeddedBuilder; @@ -27,12 +25,12 @@ public class PluginDtoCollectionMapper { } public HalRepresentation mapInstalled(List<InstalledPlugin> plugins) { - List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList()); + List<PluginDto> dtos = plugins.stream().map(mapper::mapInstalled).collect(toList()); return new HalRepresentation(createInstalledPluginsLinks(), embedDtos(dtos)); } public HalRepresentation mapAvailable(List<AvailablePlugin> plugins) { - List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList()); + List<PluginDto> dtos = plugins.stream().map(mapper::mapAvailable).collect(toList()); return new HalRepresentation(createAvailablePluginsLinks(), embedDtos(dtos)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 193aa3af26..25faf0a101 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -3,9 +3,11 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; +import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginInformation; -import sonia.scm.plugin.PluginState; +import sonia.scm.plugin.PluginPermissions; import javax.inject.Inject; @@ -20,34 +22,48 @@ public abstract class PluginDtoMapper { public abstract void map(PluginInformation plugin, @MappingTarget PluginDto dto); - public PluginDto map(Plugin plugin) { - PluginDto dto = createDto(plugin); + public PluginDto mapInstalled(InstalledPlugin plugin) { + PluginDto dto = createDtoForInstalled(plugin); + map(dto, plugin); + return dto; + } + + public PluginDto mapAvailable(AvailablePlugin plugin) { + PluginDto dto = createDtoForAvailable(plugin); + map(dto, plugin); + dto.setPending(plugin.isPending()); + return dto; + } + + private void map(PluginDto dto, Plugin plugin) { dto.setDependencies(plugin.getDescriptor().getDependencies()); map(plugin.getDescriptor().getInformation(), dto); if (dto.getCategory() == null) { dto.setCategory("Miscellaneous"); } - return dto; } - private PluginDto createDto(Plugin plugin) { - Links.Builder linksBuilder; + private PluginDto createDtoForAvailable(AvailablePlugin plugin) { + PluginInformation information = plugin.getDescriptor().getInformation(); - PluginInformation pluginInformation = plugin.getDescriptor().getInformation(); + Links.Builder links = linkingTo() + .self(resourceLinks.availablePlugin() + .self(information.getName())); - if (plugin.getState() != null && plugin.getState().equals(PluginState.AVAILABLE)) { - linksBuilder = linkingTo() - .self(resourceLinks.availablePlugin() - .self(pluginInformation.getName(), pluginInformation.getVersion())); - - linksBuilder.single(link("install", resourceLinks.availablePlugin().install(pluginInformation.getName(), pluginInformation.getVersion()))); - } - else { - linksBuilder = linkingTo() - .self(resourceLinks.installedPlugin() - .self(pluginInformation.getName())); + if (!plugin.isPending() && PluginPermissions.manage().isPermitted()) { + links.single(link("install", resourceLinks.availablePlugin().install(information.getName()))); } - return new PluginDto(linksBuilder.build()); + return new PluginDto(links.build()); + } + + private PluginDto createDtoForInstalled(InstalledPlugin plugin) { + PluginInformation information = plugin.getDescriptor().getInformation(); + + Links.Builder links = linkingTo() + .self(resourceLinks.installedPlugin() + .self(information.getName())); + + return new PluginDto(links.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 268f5f8619..6d49ca1fb6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -6,6 +6,7 @@ import javax.inject.Inject; import java.net.URI; import java.net.URISyntaxException; +@SuppressWarnings("squid:S1192") // string literals should not be duplicated class ResourceLinks { private final ScmPathInfoStore scmPathInfoStore; @@ -694,12 +695,12 @@ class ResourceLinks { availablePluginLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class); } - String self(String name, String version) { - return availablePluginLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugin").parameters(name, version).href(); + String self(String name) { + return availablePluginLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugin").parameters(name).href(); } - String install(String name, String version) { - return availablePluginLinkBuilder.method("availablePlugins").parameters().method("installPlugin").parameters(name, version).href(); + String install(String name) { + return availablePluginLinkBuilder.method("availablePlugins").parameters().method("installPlugin").parameters(name).href(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 756f1fb741..807eaac317 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -67,6 +67,7 @@ public class DefaultPluginManager implements PluginManager { private final PluginLoader loader; private final PluginCenter center; private final PluginInstaller installer; + private final List<PendingPluginInstallation> pendingQueue = new ArrayList<>(); @Inject public DefaultPluginManager(ScmEventBus eventBus, PluginLoader loader, PluginCenter center, PluginInstaller installer) { @@ -83,6 +84,15 @@ public class DefaultPluginManager implements PluginManager { .stream() .filter(filterByName(name)) .filter(this::isNotInstalled) + .map(p -> getPending(name).orElse(p)) + .findFirst(); + } + + private Optional<AvailablePlugin> getPending(String name) { + return pendingQueue + .stream() + .map(PendingPluginInstallation::getPlugin) + .filter(filterByName(name)) .findFirst(); } @@ -104,7 +114,11 @@ public class DefaultPluginManager implements PluginManager { @Override public List<AvailablePlugin> getAvailable() { PluginPermissions.read().check(); - return center.getAvailable().stream().filter(this::isNotInstalled).collect(Collectors.toList()); + return center.getAvailable() + .stream() + .filter(this::isNotInstalled) + .map(p -> getPending(p.getDescriptor().getInformation().getName()).orElse(p)) + .collect(Collectors.toList()); } private <T extends Plugin> Predicate<T> filterByName(String name) { @@ -129,11 +143,28 @@ public class DefaultPluginManager implements PluginManager { throw ex; } } - if (restartAfterInstallation) { - eventBus.post(new RestartEvent(PluginManager.class, "plugin installation")); + + if (!pendingInstallations.isEmpty()) { + if (restartAfterInstallation) { + restart("plugin installation"); + } else { + pendingQueue.addAll(pendingInstallations); + } } } + @Override + public void installPendingAndRestart() { + PluginPermissions.manage().check(); + if (!pendingQueue.isEmpty()) { + restart("install pending plugins"); + } + } + + private void restart(String cause) { + eventBus.post(new RestartEvent(PluginManager.class, cause)); + } + private void cancelPending(List<PendingPluginInstallation> pendingInstallations) { pendingInstallations.forEach(PendingPluginInstallation::cancel); } @@ -144,8 +175,12 @@ public class DefaultPluginManager implements PluginManager { return plugins; } + private boolean isInstalledOrPending(String name) { + return getInstalled(name).isPresent() || getPending(name).isPresent(); + } + private void collectPluginsToInstall(List<AvailablePlugin> plugins, String name) { - if (!getInstalled(name).isPresent()) { + if (!isInstalledOrPending(name)) { AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); Set<String> dependencies = plugin.getDescriptor().getDependencies(); @@ -157,7 +192,7 @@ public class DefaultPluginManager implements PluginManager { plugins.add(plugin); } else { - LOG.info("plugin {} is already installed, skipping installation", name); + LOG.info("plugin {} is already installed or installation is pending, skipping installation", name); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index 71a4c35a3a..88b1a469ba 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -33,7 +33,7 @@ class PluginInstaller { Files.copy(input, file); verifyChecksum(plugin, input.hash(), file); - return new PendingPluginInstallation(plugin, file); + return new PendingPluginInstallation(plugin.install(), file); } catch (IOException ex) { cleanup(file); throw new PluginDownloadException("failed to download plugin", ex); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 2a473ea63e..8023bee4dd 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -117,7 +117,7 @@ class AvailablePluginResourceTest { PluginDto pluginDto = new PluginDto(); pluginDto.setName("pluginName"); - when(mapper.map(plugin)).thenReturn(pluginDto); + when(mapper.mapAvailable(plugin)).thenReturn(pluginDto); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available/pluginName"); request.accept(VndMediaType.PLUGIN); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index c5868a1211..7fa0081c5c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -110,7 +110,7 @@ class InstalledPluginResourceTest { PluginDto pluginDto = new PluginDto(); pluginDto.setName("pluginName"); - when(mapper.map(installedPlugin)).thenReturn(pluginDto); + when(mapper.mapInstalled(installedPlugin)).thenReturn(pluginDto); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed/pluginName"); request.accept(VndMediaType.PLUGIN); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java index 3eaeb7a2cc..5cf6bdd45a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java @@ -1,15 +1,20 @@ package sonia.scm.api.v2.resources; import com.google.common.collect.ImmutableSet; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; import org.mockito.InjectMocks; -import org.mockito.Mockito; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import sonia.scm.plugin.Plugin; -import sonia.scm.plugin.PluginDescriptor; +import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.AvailablePluginDescriptor; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.PluginInformation; -import sonia.scm.plugin.PluginState; import java.net.URI; @@ -26,6 +31,19 @@ class PluginDtoMapperTest { @InjectMocks private PluginDtoMapperImpl mapper; + @Mock + private Subject subject; + + @BeforeEach + void bindSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @Test void shouldMapInformation() { PluginInformation information = createPluginInformation(); @@ -54,27 +72,42 @@ class PluginDtoMapperTest { @Test void shouldAppendInstalledSelfLink() { - Plugin plugin = createPlugin(PluginState.INSTALLED); + InstalledPlugin plugin = createInstalled(); - PluginDto dto = mapper.map(plugin); + PluginDto dto = mapper.mapInstalled(plugin); assertThat(dto.getLinks().getLinkBy("self").get().getHref()) .isEqualTo("https://hitchhiker.com/v2/plugins/installed/scm-cas-plugin"); } + private InstalledPlugin createInstalled(PluginInformation information) { + InstalledPlugin plugin = mock(InstalledPlugin.class, Answers.RETURNS_DEEP_STUBS); + when(plugin.getDescriptor().getInformation()).thenReturn(information); + return plugin; + } + @Test void shouldAppendAvailableSelfLink() { - Plugin plugin = createPlugin(PluginState.AVAILABLE); + AvailablePlugin plugin = createAvailable(); - PluginDto dto = mapper.map(plugin); + PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getLinks().getLinkBy("self").get().getHref()) .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin"); } @Test - void shouldAppendInstallLink() { - Plugin plugin = createPlugin(PluginState.AVAILABLE); + void shouldNotAppendInstallLinkWithoutPermissions() { + AvailablePlugin plugin = createAvailable(); - PluginDto dto = mapper.map(plugin); + PluginDto dto = mapper.mapAvailable(plugin); + assertThat(dto.getLinks().getLinkBy("install")).isEmpty(); + } + + @Test + void shouldAppendInstallLink() { + when(subject.isPermitted("plugin:manage")).thenReturn(true); + AvailablePlugin plugin = createAvailable(); + + PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getLinks().getLinkBy("install").get().getHref()) .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/install"); } @@ -83,31 +116,32 @@ class PluginDtoMapperTest { void shouldReturnMiscellaneousIfCategoryIsNull() { PluginInformation information = createPluginInformation(); information.setCategory(null); - Plugin plugin = createPlugin(information, PluginState.AVAILABLE); - PluginDto dto = mapper.map(plugin); + AvailablePlugin plugin = createAvailable(information); + PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getCategory()).isEqualTo("Miscellaneous"); } @Test void shouldAppendDependencies() { - Plugin plugin = createPlugin(PluginState.AVAILABLE); + AvailablePlugin plugin = createAvailable(); when(plugin.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("one", "two")); - PluginDto dto = mapper.map(plugin); + PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getDependencies()).containsOnly("one", "two"); } - private Plugin createPlugin(PluginState state) { - return createPlugin(createPluginInformation(), state); + private InstalledPlugin createInstalled() { + return createInstalled(createPluginInformation()); } - private Plugin createPlugin(PluginInformation information, PluginState state) { - Plugin plugin = Mockito.mock(Plugin.class); - when(plugin.getState()).thenReturn(state); - PluginDescriptor descriptor = mock(PluginDescriptor.class); + private AvailablePlugin createAvailable() { + return createAvailable(createPluginInformation()); + } + + private AvailablePlugin createAvailable(PluginInformation information) { + AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); when(descriptor.getInformation()).thenReturn(information); - when(plugin.getDescriptor()).thenReturn(descriptor); - return plugin; + return new AvailablePlugin(descriptor); } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index e9469c10d6..322163ee1a 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -22,6 +23,7 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.in; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; @@ -46,6 +48,14 @@ class DefaultPluginManagerTest { @Mock private Subject subject; + @BeforeEach + void mockInstaller() { + lenient().when(installer.install(any())).then(ic -> { + AvailablePlugin plugin = ic.getArgument(0); + return new PendingPluginInstallation(plugin.install(), null); + }); + } + @Nested class WithAdminPermissions { @@ -180,7 +190,10 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); - verify(installer).install(review); + ArgumentCaptor<AvailablePlugin> captor = ArgumentCaptor.forClass(AvailablePlugin.class); + verify(installer).install(captor.capture()); + + assertThat(captor.getValue().getDescriptor().getInformation().getName()).isEqualTo("scm-review-plugin"); } @Test @@ -230,6 +243,66 @@ class DefaultPluginManagerTest { verify(eventBus).post(any(RestartEvent.class)); } + @Test + void shouldNotSendRestartEventIfNoPluginWasInstalled() { + InstalledPlugin gitInstalled = createInstalled("scm-git-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(gitInstalled)); + + manager.install("scm-git-plugin", true); + verify(eventBus, never()).post(any()); + } + + @Test + void shouldNotInstallAlreadyPendingPlugins() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + + manager.install("scm-review-plugin", false); + manager.install("scm-review-plugin", false); + // only one interaction + verify(installer).install(any()); + } + + @Test + void shouldSendRestartEvent() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + + manager.install("scm-review-plugin", false); + manager.installPendingAndRestart(); + + verify(eventBus).post(any(RestartEvent.class)); + } + + @Test + void shouldNotSendRestartEventWithoutPendingPlugins() { + manager.installPendingAndRestart(); + + verify(eventBus, never()).post(any()); + } + + @Test + void shouldReturnSingleAvailableAsPending() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + + manager.install("scm-review-plugin", false); + + Optional<AvailablePlugin> available = manager.getAvailable("scm-review-plugin"); + assertThat(available.get().isPending()).isTrue(); + } + + @Test + void shouldReturnAvailableAsPending() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + + manager.install("scm-review-plugin", false); + + List<AvailablePlugin> available = manager.getAvailable(); + assertThat(available.get(0).isPending()).isTrue(); + } + } @Nested @@ -275,6 +348,11 @@ class DefaultPluginManagerTest { assertThrows(AuthorizationException.class, () -> manager.install("test", false)); } + @Test + void shouldThrowAuthorizationExceptionsForInstallPendingAndRestart() { + assertThrows(AuthorizationException.class, () -> manager.installPendingAndRestart()); + } + } private AvailablePlugin createAvailable(String name) { @@ -296,9 +374,9 @@ class DefaultPluginManagerTest { } private AvailablePlugin createAvailable(PluginInformation information) { - AvailablePlugin plugin = mock(AvailablePlugin.class, Answers.RETURNS_DEEP_STUBS); - returnInformation(plugin, information); - return plugin; + AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(information); + return new AvailablePlugin(descriptor); } private void returnInformation(Plugin mockedPlugin, PluginInformation information) { diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index 4e2de333b9..3f918cd4fa 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -63,7 +63,8 @@ class PluginInstallerTest { PendingPluginInstallation pending = installer.install(gitPlugin); assertThat(pending).isNotNull(); - assertThat(pending.getPlugin()).isSameAs(gitPlugin); + assertThat(pending.getPlugin().getDescriptor()).isEqualTo(gitPlugin.getDescriptor()); + assertThat(pending.getPlugin().isPending()).isTrue(); } private void mockContent(String content) throws IOException { From 05967aca4a761d18d64c71834f0f7be94189ddba Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 14:54:01 +0200 Subject: [PATCH 118/135] implemented ui for pending plugin installation --- .../packages/ui-types/src/Plugin.js | 1 + scm-ui/public/locales/de/admin.json | 4 +- scm-ui/public/locales/en/admin.json | 4 +- .../components/InstallPendingAction.js | 68 +++++++++ .../plugins/components/InstallPendingModal.js | 136 ++++++++++++++++++ .../plugins/components/PluginBottomActions.js | 30 ++++ .../admin/plugins/components/PluginEntry.js | 48 ++++--- .../{PluginsList.js => PluginList.js} | 0 .../plugins/components/PluginTopActions.js | 32 +++++ .../plugins/containers/PluginsOverview.js | 60 ++++++-- .../v2/resources/AvailablePluginResource.java | 12 ++ .../resources/PluginDtoCollectionMapper.java | 17 ++- .../scm/api/v2/resources/ResourceLinks.java | 4 + .../AvailablePluginResourceTest.java | 11 ++ 14 files changed, 394 insertions(+), 33 deletions(-) create mode 100644 scm-ui/src/admin/plugins/components/InstallPendingAction.js create mode 100644 scm-ui/src/admin/plugins/components/InstallPendingModal.js create mode 100644 scm-ui/src/admin/plugins/components/PluginBottomActions.js rename scm-ui/src/admin/plugins/components/{PluginsList.js => PluginList.js} (100%) create mode 100644 scm-ui/src/admin/plugins/components/PluginTopActions.js diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 157ef06098..0114716757 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -9,6 +9,7 @@ export type Plugin = { author: string, category: string, avatarUrl: string, + pending: boolean, dependencies: string[], _links: Links }; diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 19e8a07751..20a1e0a004 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -29,6 +29,7 @@ "installedNavLink": "Installiert", "availableNavLink": "Verfügbar" }, + "installPending": "Austehende Plugins installieren", "noPlugins": "Keine Plugins gefunden.", "modal": { "title": "{{name}} Plugin installieren", @@ -42,7 +43,8 @@ "dependencies": "Abhängigkeiten", "successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:", "reload": "jetzt new laden", - "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet." + "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.", + "installPending": "Die folgenden Plugins werden installiert und anschließend wir der SCM-Manager Kontext neu gestartet." } }, "repositoryRole": { diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index afdc75585a..2c9304ac85 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -29,6 +29,7 @@ "installedNavLink": "Installed", "availableNavLink": "Available" }, + "installPending": "Install pending plugins", "noPlugins": "No plugins found.", "modal": { "title": "Install {{name}} Plugin", @@ -42,7 +43,8 @@ "dependencies": "Dependencies", "successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:", "reload": "reload now", - "restartNotification": "Restarting the scm-manager context, should only be done if no one else is currently working with it." + "restartNotification": "Restarting the scm-manager context, should only be done if no one else is currently working with it.", + "installPending": "The following plugins will be installed and after installation the scm-manager context will be restarted." } }, "repositoryRole": { diff --git a/scm-ui/src/admin/plugins/components/InstallPendingAction.js b/scm-ui/src/admin/plugins/components/InstallPendingAction.js new file mode 100644 index 0000000000..b46b3fba53 --- /dev/null +++ b/scm-ui/src/admin/plugins/components/InstallPendingAction.js @@ -0,0 +1,68 @@ +// @flow +import React from "react"; +import { Button, ButtonGroup, Modal } from "@scm-manager/ui-components"; +import type { PluginCollection } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; +import InstallPendingModal from "./InstallPendingModal"; + +type Props = { + collection: PluginCollection, + + // context props + t: string => string +}; + +type State = { + showModal: boolean +}; + +class InstallPendingAction extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + this.state = { + showModal: false + }; + } + + openModal = () => { + this.setState({ + showModal: true + }); + }; + + closeModal = () => { + this.setState({ + showModal: false + }); + }; + + renderModal = () => { + const { showModal } = this.state; + const { collection } = this.props; + if (showModal) { + return ( + <InstallPendingModal + collection={collection} + onClose={this.closeModal} + /> + ); + } + return null; + }; + + render() { + const { t } = this.props; + return ( + <> + {this.renderModal()} + <Button + color="primary" + label={t("plugins.installPending")} + action={this.openModal} + /> + </> + ); + } +} + +export default translate("admin")(InstallPendingAction); diff --git a/scm-ui/src/admin/plugins/components/InstallPendingModal.js b/scm-ui/src/admin/plugins/components/InstallPendingModal.js new file mode 100644 index 0000000000..c0094d8d4f --- /dev/null +++ b/scm-ui/src/admin/plugins/components/InstallPendingModal.js @@ -0,0 +1,136 @@ +// @flow +import React from "react"; +import { + apiClient, + Button, + ButtonGroup, + ErrorNotification, + Modal, + Notification +} from "@scm-manager/ui-components"; +import type { PluginCollection } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; + +type Props = { + onClose: () => void, + collection: PluginCollection, + + // context props + t: string => string +}; + +type State = { + loading: boolean, + success: boolean, + error?: Error +}; + +class InstallPendingModal extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + this.state = { + loading: false, + success: false + }; + } + + renderNotifications = () => { + const { t } = this.props; + const { error, success } = this.state; + if (error) { + return <ErrorNotification error={error} />; + } else if (success) { + return ( + <Notification type="success"> + {t("plugins.modal.successNotification")}{" "} + <a onClick={e => window.location.reload()}> + {t("plugins.modal.reload")} + </a> + </Notification> + ); + } else { + return ( + <Notification type="warning"> + {t("plugins.modal.restartNotification")} + </Notification> + ); + } + }; + + installAndRestart = () => { + const { collection } = this.props; + this.setState({ + loading: true + }); + + apiClient + .post(collection._links.installPending.href) + .then(() => { + this.setState({ + success: true, + loading: false, + error: undefined + }); + }) + .catch(error => { + this.setState({ + success: false, + loading: false, + error: error + }); + }); + }; + + renderBody = () => { + const { collection, t } = this.props; + return ( + <> + <div className="media"> + <div className="content"> + <p>{t("plugins.modal.installPending")}</p> + <ul> + {collection._embedded.plugins + .filter(plugin => plugin.pending) + .map(plugin => ( + <li key={plugin.name} className="has-text-weight-bold">{plugin.name}</li> + ))} + </ul> + </div> + </div> + <div className="media">{this.renderNotifications()}</div> + </> + ); + }; + + renderFooter = () => { + const { onClose, t } = this.props; + const { loading, error, success } = this.state; + return ( + <ButtonGroup> + <Button + color="warning" + label={t("plugins.modal.installAndRestart")} + loading={loading} + action={this.installAndRestart} + disabled={error || success} + /> + <Button label={t("plugins.modal.abort")} action={onClose} /> + </ButtonGroup> + ); + }; + + render() { + const { onClose, t } = this.props; + return ( + <Modal + title={t("plugins.modal.installAndRestart")} + closeFunction={onClose} + body={this.renderBody()} + footer={this.renderFooter()} + active={true} + /> + ); + } +} + +export default translate("admin")(InstallPendingModal); diff --git a/scm-ui/src/admin/plugins/components/PluginBottomActions.js b/scm-ui/src/admin/plugins/components/PluginBottomActions.js new file mode 100644 index 0000000000..668fc0d285 --- /dev/null +++ b/scm-ui/src/admin/plugins/components/PluginBottomActions.js @@ -0,0 +1,30 @@ +// @flow +import * as React from "react"; +import classNames from "classnames"; +import injectSheet from "react-jss"; + +const styles = { + container: { + border: "2px solid #e9f7fd", + padding: "1em 1em", + marginTop: "2em", + display: "flex", + justifyContent: "center" + } +}; + +type Props = { + children?: React.Node, + + // context props + classes: any +}; + +class PluginBottomActions extends React.Component<Props> { + render() { + const { children, classes } = this.props; + return <div className={classNames(classes.container)}>{children}</div>; + } +} + +export default injectSheet(styles)(PluginBottomActions); diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index fa62786fc2..c7624a02ef 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -5,6 +5,7 @@ import type { Plugin } from "@scm-manager/ui-types"; import { CardColumn } from "@scm-manager/ui-components"; import PluginAvatar from "./PluginAvatar"; import PluginModal from "./PluginModal"; +import classNames from "classnames"; type Props = { plugin: Plugin, @@ -43,34 +44,48 @@ class PluginEntry extends React.Component<Props, State> { })); }; - createContentRight = (plugin: Plugin) => { - const { classes } = this.props; - if (plugin._links && plugin._links.install && plugin._links.install.href) { - return ( - <div className={classes.link} onClick={this.toggleModal}> - <i className="fas fa-download fa-2x has-text-info" /> - </div> - ); - } - }; - - createFooterLeft = (plugin: Plugin) => { + createFooterRight = (plugin: Plugin) => { return <small className="level-item">{plugin.author}</small>; }; - createFooterRight = (plugin: Plugin) => { - return <p className="level-item">{plugin.version}</p>; + createFooterLeft = (plugin: Plugin) => { + const { classes } = this.props; + if (plugin.pending) { + return ( + <span className="level-item"> + <i className="fas fa-spinner fa-spin has-text-info" /> + </span> + ); + } else if ( + plugin._links && + plugin._links.install && + plugin._links.install.href + ) { + return ( + <span + className={classNames(classes.link, "level-item")} + onClick={this.toggleModal} + > + <i className="fas fa-download has-text-info" /> + </span> + ); + } }; render() { const { plugin } = this.props; const { showModal } = this.state; const avatar = this.createAvatar(plugin); - const contentRight = this.createContentRight(plugin); const footerLeft = this.createFooterLeft(plugin); const footerRight = this.createFooterRight(plugin); - const modal = showModal ? <PluginModal plugin={plugin} onSubmit={this.toggleModal} onClose={this.toggleModal} /> : null; + const modal = showModal ? ( + <PluginModal + plugin={plugin} + onSubmit={this.toggleModal} + onClose={this.toggleModal} + /> + ) : null; // TODO: Add link to plugin page below return ( @@ -80,7 +95,6 @@ class PluginEntry extends React.Component<Props, State> { avatar={avatar} title={plugin.displayName ? plugin.displayName : plugin.name} description={plugin.description} - contentRight={contentRight} footerLeft={footerLeft} footerRight={footerRight} /> diff --git a/scm-ui/src/admin/plugins/components/PluginsList.js b/scm-ui/src/admin/plugins/components/PluginList.js similarity index 100% rename from scm-ui/src/admin/plugins/components/PluginsList.js rename to scm-ui/src/admin/plugins/components/PluginList.js diff --git a/scm-ui/src/admin/plugins/components/PluginTopActions.js b/scm-ui/src/admin/plugins/components/PluginTopActions.js new file mode 100644 index 0000000000..d30c755eb2 --- /dev/null +++ b/scm-ui/src/admin/plugins/components/PluginTopActions.js @@ -0,0 +1,32 @@ +// @flow +import * as React from "react"; +import classNames from "classnames"; +import injectSheet from "react-jss"; + +const styles = { + container: { + display: "flex", + justifyContent: "flex-end", + alignItems: "center" + } +}; + +type Props = { + children?: React.Node, + + // context props + classes: any +}; + +class PluginTopActions extends React.Component<Props> { + render() { + const { children, classes } = this.props; + return ( + <div className={classNames(classes.container, "column", "is-one-fifths", "is-mobile-action-spacing")}> + {children} + </div> + ); + } +} + +export default injectSheet(styles)(PluginTopActions); diff --git a/scm-ui/src/admin/plugins/containers/PluginsOverview.js b/scm-ui/src/admin/plugins/containers/PluginsOverview.js index 5a8a46f884..375e2ebd6c 100644 --- a/scm-ui/src/admin/plugins/containers/PluginsOverview.js +++ b/scm-ui/src/admin/plugins/containers/PluginsOverview.js @@ -1,5 +1,5 @@ // @flow -import React from "react"; +import * as React from "react"; import { connect } from "react-redux"; import { translate } from "react-i18next"; import { compose } from "redux"; @@ -9,7 +9,8 @@ import { Title, Subtitle, Notification, - ErrorNotification + ErrorNotification, + Button } from "@scm-manager/ui-components"; import { fetchPluginsByLink, @@ -17,11 +18,14 @@ import { getPluginCollection, isFetchPluginsPending } from "../modules/plugins"; -import PluginsList from "../components/PluginsList"; +import PluginsList from "../components/PluginList"; import { getAvailablePluginsLink, getInstalledPluginsLink } from "../../../modules/indexResource"; +import PluginTopActions from "../components/PluginTopActions"; +import PluginBottomActions from "../components/PluginBottomActions"; +import InstallPendingAction from '../components/InstallPendingAction'; type Props = { loading: boolean, @@ -64,8 +68,44 @@ class PluginsOverview extends React.Component<Props> { } } + renderHeader = (actions: React.Node) => { + const { installed, t } = this.props; + return ( + <div className="columns"> + <div className="column"> + <Title title={t("plugins.title")} /> + <Subtitle + subtitle={ + installed + ? t("plugins.installedSubtitle") + : t("plugins.availableSubtitle") + } + /> + </div> + <PluginTopActions>{actions}</PluginTopActions> + </div> + ); + }; + + renderFooter = (actions: React.Node) => { + if (actions) { + return <PluginBottomActions>{actions}</PluginBottomActions>; + } + return null; + }; + + createActions = () => { + const { collection } = this.props; + if (collection._links.installPending) { + return ( + <InstallPendingAction collection={collection} /> + ); + } + return null; + }; + render() { - const { loading, error, collection, installed, t } = this.props; + const { loading, error, collection } = this.props; if (error) { return <ErrorNotification error={error} />; @@ -75,17 +115,13 @@ class PluginsOverview extends React.Component<Props> { return <Loading />; } + const actions = this.createActions(); return ( <> - <Title title={t("plugins.title")} /> - <Subtitle - subtitle={ - installed - ? t("plugins.installedSubtitle") - : t("plugins.availableSubtitle") - } - /> + {this.renderHeader(actions)} + <hr className="header-with-actions" /> {this.renderPluginsList()} + {this.renderFooter(actions)} </> ); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index e54c6cf85e..fa06740945 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -95,4 +95,16 @@ public class AvailablePluginResource { pluginManager.install(name, restartAfterInstallation); return Response.ok().build(); } + + @POST + @Path("/install-pending") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response installPending() { + PluginPermissions.manage().check(); + pluginManager.installPendingAndRestart(); + return Response.ok().build(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index bcfbce3f04..d04f34ce4a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -3,9 +3,11 @@ package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.InstalledPlugin; +import sonia.scm.plugin.PluginPermissions; import java.util.List; @@ -31,7 +33,7 @@ public class PluginDtoCollectionMapper { public HalRepresentation mapAvailable(List<AvailablePlugin> plugins) { List<PluginDto> dtos = plugins.stream().map(mapper::mapAvailable).collect(toList()); - return new HalRepresentation(createAvailablePluginsLinks(), embedDtos(dtos)); + return new HalRepresentation(createAvailablePluginsLinks(plugins), embedDtos(dtos)); } private Links createInstalledPluginsLinks() { @@ -42,14 +44,25 @@ public class PluginDtoCollectionMapper { return linksBuilder.build(); } - private Links createAvailablePluginsLinks() { + private Links createAvailablePluginsLinks(List<AvailablePlugin> plugins) { String baseUrl = resourceLinks.availablePluginCollection().self(); Links.Builder linksBuilder = linkingTo() .with(Links.linkingTo().self(baseUrl).build()); + + if (PluginPermissions.manage().isPermitted()) { + if (containsPending(plugins)) { + linksBuilder.single(Link.link("installPending", resourceLinks.availablePluginCollection().installPending())); + } + } + return linksBuilder.build(); } + private boolean containsPending(List<AvailablePlugin> plugins) { + return plugins.stream().anyMatch(AvailablePlugin::isPending); + } + private Embedded embedDtos(List<PluginDto> dtos) { return embeddedBuilder() .with("plugins", dtos) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 6d49ca1fb6..bf92c567cd 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -715,6 +715,10 @@ class ResourceLinks { availablePluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class); } + String installPending() { + return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("installPending").parameters().href(); + } + String self() { return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugins").parameters().href(); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 8023bee4dd..c108d4ee7a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -139,6 +139,17 @@ class AvailablePluginResourceTest { verify(pluginManager).install("pluginName", false); assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); } + + @Test + void installPendingPlugin() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/install-pending"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + verify(pluginManager).installPendingAndRestart(); + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + } } private AvailablePlugin createPlugin() { From 707d3d2fd78edc37c35102bcf2b3bbdf7e2aaeb4 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 15:07:56 +0200 Subject: [PATCH 119/135] refresh plugin list after installation --- .../admin/plugins/components/PluginEntry.js | 5 ++-- .../plugins/components/PluginGroupEntry.js | 9 +++--- .../admin/plugins/components/PluginList.js | 7 +++-- .../admin/plugins/components/PluginModal.js | 9 ++++-- .../plugins/containers/PluginsOverview.js | 30 +++++++++++-------- 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index c7624a02ef..0ed128e75b 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -9,6 +9,7 @@ import classNames from "classnames"; type Props = { plugin: Plugin, + refresh: () => void, // context props classes: any @@ -73,7 +74,7 @@ class PluginEntry extends React.Component<Props, State> { }; render() { - const { plugin } = this.props; + const { plugin, refresh } = this.props; const { showModal } = this.state; const avatar = this.createAvatar(plugin); const footerLeft = this.createFooterLeft(plugin); @@ -82,7 +83,7 @@ class PluginEntry extends React.Component<Props, State> { const modal = showModal ? ( <PluginModal plugin={plugin} - onSubmit={this.toggleModal} + refresh={refresh} onClose={this.toggleModal} /> ) : null; diff --git a/scm-ui/src/admin/plugins/components/PluginGroupEntry.js b/scm-ui/src/admin/plugins/components/PluginGroupEntry.js index 44046eb6ab..4255606742 100644 --- a/scm-ui/src/admin/plugins/components/PluginGroupEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginGroupEntry.js @@ -5,14 +5,15 @@ import type { PluginGroup } from "@scm-manager/ui-types"; import PluginEntry from "./PluginEntry"; type Props = { - group: PluginGroup + group: PluginGroup, + refresh: () => void }; class PluginGroupEntry extends React.Component<Props> { render() { - const { group } = this.props; - const entries = group.plugins.map((plugin, index) => { - return <PluginEntry plugin={plugin} key={index} />; + const { group, refresh } = this.props; + const entries = group.plugins.map(plugin => { + return <PluginEntry plugin={plugin} key={plugin.name} refresh={refresh} />; }); return <CardColumnGroup name={group.name} elements={entries} />; } diff --git a/scm-ui/src/admin/plugins/components/PluginList.js b/scm-ui/src/admin/plugins/components/PluginList.js index e04d78d46e..bc8cfe7197 100644 --- a/scm-ui/src/admin/plugins/components/PluginList.js +++ b/scm-ui/src/admin/plugins/components/PluginList.js @@ -5,18 +5,19 @@ import PluginGroupEntry from "../components/PluginGroupEntry"; import groupByCategory from "./groupByCategory"; type Props = { - plugins: Plugin[] + plugins: Plugin[], + refresh: () => void }; class PluginList extends React.Component<Props> { render() { - const { plugins } = this.props; + const { plugins, refresh } = this.props; const groups = groupByCategory(plugins); return ( <div className="content is-plugin-page"> {groups.map(group => { - return <PluginGroupEntry group={group} key={group.name} />; + return <PluginGroupEntry group={group} key={group.name} refresh={refresh} />; })} </div> ); diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index 78af2d8bec..8f65ba9349 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -17,7 +17,7 @@ import classNames from "classnames"; type Props = { plugin: Plugin, - onSubmit: () => void, + refresh: () => void, onClose: () => void, // context props @@ -55,7 +55,7 @@ class PluginModal extends React.Component<Props, State> { onInstallSuccess = () => { const { restart } = this.state; - const { onClose } = this.props; + const { refresh, onClose } = this.props; const newState = { loading: false, @@ -68,7 +68,10 @@ class PluginModal extends React.Component<Props, State> { success: true }); } else { - this.setState(newState, onClose); + this.setState(newState, () => { + refresh(); + onClose(); + }); } }; diff --git a/scm-ui/src/admin/plugins/containers/PluginsOverview.js b/scm-ui/src/admin/plugins/containers/PluginsOverview.js index 375e2ebd6c..ce4324cd3d 100644 --- a/scm-ui/src/admin/plugins/containers/PluginsOverview.js +++ b/scm-ui/src/admin/plugins/containers/PluginsOverview.js @@ -9,8 +9,7 @@ import { Title, Subtitle, Notification, - ErrorNotification, - Button + ErrorNotification } from "@scm-manager/ui-components"; import { fetchPluginsByLink, @@ -25,7 +24,7 @@ import { } from "../../../modules/indexResource"; import PluginTopActions from "../components/PluginTopActions"; import PluginBottomActions from "../components/PluginBottomActions"; -import InstallPendingAction from '../components/InstallPendingAction'; +import InstallPendingAction from "../components/InstallPendingAction"; type Props = { loading: boolean, @@ -55,18 +54,25 @@ class PluginsOverview extends React.Component<Props> { } componentDidUpdate(prevProps) { + const { + installed, + } = this.props; + if (prevProps.installed !== installed) { + this.fetchPlugins(); + } + } + + fetchPlugins = () => { const { installed, fetchPluginsByLink, availablePluginsLink, installedPluginsLink } = this.props; - if (prevProps.installed !== installed) { - fetchPluginsByLink( - installed ? installedPluginsLink : availablePluginsLink - ); - } - } + fetchPluginsByLink( + installed ? installedPluginsLink : availablePluginsLink + ); + }; renderHeader = (actions: React.Node) => { const { installed, t } = this.props; @@ -97,9 +103,7 @@ class PluginsOverview extends React.Component<Props> { createActions = () => { const { collection } = this.props; if (collection._links.installPending) { - return ( - <InstallPendingAction collection={collection} /> - ); + return <InstallPendingAction collection={collection} />; } return null; }; @@ -130,7 +134,7 @@ class PluginsOverview extends React.Component<Props> { const { collection, t } = this.props; if (collection._embedded && collection._embedded.plugins.length > 0) { - return <PluginsList plugins={collection._embedded.plugins} />; + return <PluginsList plugins={collection._embedded.plugins} refresh={this.fetchPlugins} />; } return <Notification type="info">{t("plugins.noPlugins")}</Notification>; } From e64699bfc884e600be480a212ed4dc641c4088cd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 15:42:03 +0200 Subject: [PATCH 120/135] make the whole plugin card clickable and moved spinner to upper right corner --- .../packages/ui-components/src/CardColumn.js | 7 +++- .../admin/plugins/components/PluginEntry.js | 42 ++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/CardColumn.js b/scm-ui-components/packages/ui-components/src/CardColumn.js index 65710c7be2..713e1bced3 100644 --- a/scm-ui-components/packages/ui-components/src/CardColumn.js +++ b/scm-ui-components/packages/ui-components/src/CardColumn.js @@ -46,7 +46,8 @@ type Props = { contentRight?: React.Node, footerLeft: React.Node, footerRight: React.Node, - link: string, + link?: string, + action?: () => void, // context props classes: any @@ -54,9 +55,11 @@ type Props = { class CardColumn extends React.Component<Props> { createLink = () => { - const { link } = this.props; + const { link, action } = this.props; if (link) { return <Link className="overlay-column" to={link} />; + } else if (action) { + return <a className="overlay-column" onClick={e => {e.preventDefault(); action();}} href="#" />; } return null; }; diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index 0ed128e75b..7a8bf6cf6c 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -23,6 +23,11 @@ const styles = { link: { cursor: "pointer", pointerEvents: "all" + }, + spinner: { + position: "absolute", + right: 0, + top: 0 } }; @@ -49,19 +54,14 @@ class PluginEntry extends React.Component<Props, State> { return <small className="level-item">{plugin.author}</small>; }; - createFooterLeft = (plugin: Plugin) => { + isInstallable = () => { + const { plugin } = this.props; + return plugin._links && plugin._links.install && plugin._links.install.href; + }; + + createFooterLeft = () => { const { classes } = this.props; - if (plugin.pending) { - return ( - <span className="level-item"> - <i className="fas fa-spinner fa-spin has-text-info" /> - </span> - ); - } else if ( - plugin._links && - plugin._links.install && - plugin._links.install.href - ) { + if (this.isInstallable()) { return ( <span className={classNames(classes.link, "level-item")} @@ -73,11 +73,23 @@ class PluginEntry extends React.Component<Props, State> { } }; + createPendingSpinner = () => { + const { plugin, classes } = this.props; + if (plugin.pending) { + return ( + <span className={classes.spinner}> + <i className="fas fa-spinner fa-spin has-text-info" /> + </span> + ); + } + return null; + }; + render() { const { plugin, refresh } = this.props; const { showModal } = this.state; const avatar = this.createAvatar(plugin); - const footerLeft = this.createFooterLeft(plugin); + const footerLeft = this.createFooterLeft(); const footerRight = this.createFooterRight(plugin); const modal = showModal ? ( @@ -88,14 +100,14 @@ class PluginEntry extends React.Component<Props, State> { /> ) : null; - // TODO: Add link to plugin page below return ( <> <CardColumn - link="#" + action={this.isInstallable() ? this.toggleModal : null} avatar={avatar} title={plugin.displayName ? plugin.displayName : plugin.name} description={plugin.description} + contentRight={this.createPendingSpinner()} footerLeft={footerLeft} footerRight={footerRight} /> From 0ce9aeb40003bb4c8a69397f6f44e9bef832fc01 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 16:10:17 +0200 Subject: [PATCH 121/135] remove deprecated PluginState --- .../sonia/scm/plugin/AvailablePlugin.java | 5 -- .../sonia/scm/plugin/InstalledPlugin.java | 5 -- .../main/java/sonia/scm/plugin/Plugin.java | 10 --- .../java/sonia/scm/plugin/PluginState.java | 74 ------------------- .../scm/plugin/PluginCenterDtoMapperTest.java | 16 +++- 5 files changed, 12 insertions(+), 98 deletions(-) delete mode 100644 scm-core/src/main/java/sonia/scm/plugin/PluginState.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java b/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java index 2353f5a10e..db8d96ca15 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/AvailablePlugin.java @@ -21,11 +21,6 @@ public class AvailablePlugin implements Plugin { return pluginDescriptor; } - @Override - public PluginState getState() { - return PluginState.AVAILABLE; - } - public boolean isPending() { return pending; } diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java index fc1fbac94a..2021d4d00f 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -120,11 +120,6 @@ public final class InstalledPlugin implements Plugin return webResourceLoader; } - @Override - public PluginState getState() { - return PluginState.INSTALLED; - } - //~--- fields --------------------------------------------------------------- /** plugin class loader */ diff --git a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java b/scm-core/src/main/java/sonia/scm/plugin/Plugin.java index 40f5ec2b49..8b440f8ab9 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/Plugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/Plugin.java @@ -3,14 +3,4 @@ package sonia.scm.plugin; public interface Plugin { PluginDescriptor getDescriptor(); - - /** - * Returns plugin state. - * - * @deprecated State is now derived from concrete plugin implementations - * @return plugin state - */ - @Deprecated - PluginState getState(); - } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginState.java b/scm-core/src/main/java/sonia/scm/plugin/PluginState.java deleted file mode 100644 index 39803d3455..0000000000 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginState.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.plugin; - -/** - * - * @author Sebastian Sdorra - */ -public enum PluginState -{ - CORE(100), AVAILABLE(60), INSTALLED(80), NEWER_VERSION_INSTALLED(20), - UPDATE_AVAILABLE(40); - - /** - * Constructs ... - * - * - * @param compareValue - */ - private PluginState(int compareValue) - { - this.compareValue = compareValue; - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @since 1.6 - * @return - */ - public int getCompareValue() - { - return compareValue; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final int compareValue; -} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index af4aac0e0d..af6449794d 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -98,10 +98,8 @@ class PluginCenterDtoMapperTest { Set<AvailablePlugin> resultSet = mapper.map(dto); - List<AvailablePlugin> pluginsList = new ArrayList<>(resultSet); - - PluginInformation pluginInformation1 = pluginsList.get(1).getDescriptor().getInformation(); - PluginInformation pluginInformation2 = pluginsList.get(0).getDescriptor().getInformation(); + PluginInformation pluginInformation1 = findPlugin(resultSet, plugin1.getName()); + PluginInformation pluginInformation2 = findPlugin(resultSet, plugin2.getName()); assertThat(pluginInformation1.getAuthor()).isEqualTo(plugin1.getAuthor()); assertThat(pluginInformation1.getVersion()).isEqualTo(plugin1.getVersion()); @@ -109,4 +107,14 @@ class PluginCenterDtoMapperTest { assertThat(pluginInformation2.getVersion()).isEqualTo(plugin2.getVersion()); assertThat(resultSet.size()).isEqualTo(2); } + + private PluginInformation findPlugin(Set<AvailablePlugin> resultSet, String name) { + return resultSet + .stream() + .filter(p -> name.equals(p.getDescriptor().getInformation().getName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("could not find plugin " + name)) + .getDescriptor() + .getInformation(); + } } From 6351e39c12e50cb143618e9a308f0286f3e4a5cd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Wed, 21 Aug 2019 16:46:50 +0200 Subject: [PATCH 122/135] fix some code smells reported by SonarQube --- scm-ui/src/admin/plugins/components/InstallPendingAction.js | 2 +- .../scm/api/v2/resources/PluginDtoCollectionMapper.java | 6 ++---- .../src/main/java/sonia/scm/plugin/PluginInstaller.java | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/scm-ui/src/admin/plugins/components/InstallPendingAction.js b/scm-ui/src/admin/plugins/components/InstallPendingAction.js index b46b3fba53..49a444de11 100644 --- a/scm-ui/src/admin/plugins/components/InstallPendingAction.js +++ b/scm-ui/src/admin/plugins/components/InstallPendingAction.js @@ -1,6 +1,6 @@ // @flow import React from "react"; -import { Button, ButtonGroup, Modal } from "@scm-manager/ui-components"; +import { Button } from "@scm-manager/ui-components"; import type { PluginCollection } from "@scm-manager/ui-types"; import { translate } from "react-i18next"; import InstallPendingModal from "./InstallPendingModal"; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index d04f34ce4a..7c1ee3d5a6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -50,10 +50,8 @@ public class PluginDtoCollectionMapper { Links.Builder linksBuilder = linkingTo() .with(Links.linkingTo().self(baseUrl).build()); - if (PluginPermissions.manage().isPermitted()) { - if (containsPending(plugins)) { - linksBuilder.single(Link.link("installPending", resourceLinks.availablePluginCollection().installPending())); - } + if (PluginPermissions.manage().isPermitted() && containsPending(plugins)) { + linksBuilder.single(Link.link("installPending", resourceLinks.availablePluginCollection().installPending())); } return linksBuilder.build(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index 88b1a469ba..6f003c1e31 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -26,6 +26,7 @@ class PluginInstaller { this.client = client; } + @SuppressWarnings("squid:S4790") // hashing should be safe public PendingPluginInstallation install(AvailablePlugin plugin) { Path file = null; try (HashingInputStream input = new HashingInputStream(Hashing.sha256(), download(plugin))) { From b796e45fb48f966568083bc462fb406335fb634d Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Thu, 22 Aug 2019 08:24:01 +0200 Subject: [PATCH 123/135] wait until restart is complete --- .../plugins/components/InstallPendingModal.js | 16 +++++----- .../components/InstallSuccessNotification.js | 25 ++++++++++++++++ .../admin/plugins/components/PluginModal.js | 27 ++++++++++------- .../plugins/components/waitForRestart.js | 30 +++++++++++++++++++ 4 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 scm-ui/src/admin/plugins/components/InstallSuccessNotification.js create mode 100644 scm-ui/src/admin/plugins/components/waitForRestart.js diff --git a/scm-ui/src/admin/plugins/components/InstallPendingModal.js b/scm-ui/src/admin/plugins/components/InstallPendingModal.js index c0094d8d4f..e79d815b3d 100644 --- a/scm-ui/src/admin/plugins/components/InstallPendingModal.js +++ b/scm-ui/src/admin/plugins/components/InstallPendingModal.js @@ -10,6 +10,8 @@ import { } from "@scm-manager/ui-components"; import type { PluginCollection } from "@scm-manager/ui-types"; import { translate } from "react-i18next"; +import waitForRestart from "./waitForRestart"; +import InstallSuccessNotification from "./InstallSuccessNotification"; type Props = { onClose: () => void, @@ -40,14 +42,7 @@ class InstallPendingModal extends React.Component<Props, State> { if (error) { return <ErrorNotification error={error} />; } else if (success) { - return ( - <Notification type="success"> - {t("plugins.modal.successNotification")}{" "} - <a onClick={e => window.location.reload()}> - {t("plugins.modal.reload")} - </a> - </Notification> - ); + return <InstallSuccessNotification />; } else { return ( <Notification type="warning"> @@ -65,6 +60,7 @@ class InstallPendingModal extends React.Component<Props, State> { apiClient .post(collection._links.installPending.href) + .then(waitForRestart) .then(() => { this.setState({ success: true, @@ -92,7 +88,9 @@ class InstallPendingModal extends React.Component<Props, State> { {collection._embedded.plugins .filter(plugin => plugin.pending) .map(plugin => ( - <li key={plugin.name} className="has-text-weight-bold">{plugin.name}</li> + <li key={plugin.name} className="has-text-weight-bold"> + {plugin.name} + </li> ))} </ul> </div> diff --git a/scm-ui/src/admin/plugins/components/InstallSuccessNotification.js b/scm-ui/src/admin/plugins/components/InstallSuccessNotification.js new file mode 100644 index 0000000000..daebb8a8d0 --- /dev/null +++ b/scm-ui/src/admin/plugins/components/InstallSuccessNotification.js @@ -0,0 +1,25 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import { Notification } from "@scm-manager/ui-components"; + +type Props = { + // context props + t: string => string +}; + +class InstallSuccessNotification extends React.Component<Props> { + render() { + const { t } = this.props; + return ( + <Notification type="success"> + {t("plugins.modal.successNotification")}{" "} + <a onClick={e => window.location.reload(true)}> + {t("plugins.modal.reload")} + </a> + </Notification> + ); + } +} + +export default translate("admin")(InstallSuccessNotification); diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index 8f65ba9349..4440bf0ef3 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -14,6 +14,8 @@ import { Notification } from "@scm-manager/ui-components"; import classNames from "classnames"; +import waitForRestart from "./waitForRestart"; +import InstallSuccessNotification from "./InstallSuccessNotification"; type Props = { plugin: Plugin, @@ -63,10 +65,20 @@ class PluginModal extends React.Component<Props, State> { }; if (restart) { - this.setState({ - ...newState, - success: true - }); + waitForRestart() + .then(() => { + this.setState({ + ...newState, + success: true + }); + }) + .catch(error => { + this.setState({ + loading: false, + success: false, + error + }); + }); } else { this.setState(newState, () => { refresh(); @@ -150,12 +162,7 @@ class PluginModal extends React.Component<Props, State> { } else if (success) { return ( <div className="media"> - <Notification type="success"> - {t("plugins.modal.successNotification")}{" "} - <a onClick={e => window.location.reload()}> - {t("plugins.modal.reload")} - </a> - </Notification> + <InstallSuccessNotification /> </div> ); } else if (restart) { diff --git a/scm-ui/src/admin/plugins/components/waitForRestart.js b/scm-ui/src/admin/plugins/components/waitForRestart.js new file mode 100644 index 0000000000..93b7b17855 --- /dev/null +++ b/scm-ui/src/admin/plugins/components/waitForRestart.js @@ -0,0 +1,30 @@ +// @flow +import { apiClient } from "@scm-manager/ui-components"; + +const waitForRestart = () => { + const endTime = Number(new Date()) + 10000; + let started = false; + + const executor = (resolve, reject) => { + // we need some initial delay + if (!started) { + started = true; + setTimeout(executor, 100, resolve, reject); + } else { + apiClient + .get("") + .then(resolve) + .catch(() => { + if (Number(new Date()) < endTime) { + setTimeout(executor, 500, resolve, reject); + } else { + reject(new Error("timeout reached")); + } + }); + } + }; + + return new Promise<void>(executor); +}; + +export default waitForRestart; From 2c0e4ee8410a72894f85c1a972f70dde2a66c3ae Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Thu, 22 Aug 2019 08:51:18 +0000 Subject: [PATCH 124/135] Close branch feature/install_plugins From 74cc7b53e8dc57099ef6b3c12557293431c716a4 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Thu, 22 Aug 2019 10:53:14 +0200 Subject: [PATCH 125/135] Peer-Review changes --- scm-ui/public/locales/de/admin.json | 6 +++--- scm-ui/public/locales/en/admin.json | 4 ++-- scm-ui/src/admin/plugins/components/waitForRestart.js | 2 +- .../sonia/scm/api/v2/resources/AvailablePluginResource.java | 2 +- .../sonia/scm/api/v2/resources/InstalledPluginResource.java | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 20a1e0a004..8d9c876537 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -39,12 +39,12 @@ "abort": "Abbrechen", "author": "Autor", "version": "Version", - "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installieren wenn sie noch nicht vorhanden sind!", + "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installiert, wenn sie noch nicht vorhanden sind!", "dependencies": "Abhängigkeiten", "successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:", - "reload": "jetzt new laden", + "reload": "jetzt neu laden", "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.", - "installPending": "Die folgenden Plugins werden installiert und anschließend wir der SCM-Manager Kontext neu gestartet." + "installPending": "Die folgenden Plugins werden installiert. Anschließend wird der SCM-Manager Kontext neu gestartet." } }, "repositoryRole": { diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 2c9304ac85..7c9adff4f9 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -39,11 +39,11 @@ "abort": "Abort", "author": "Author", "version": "Version", - "dependencyNotification": "With this plugin, the following dependencies are installed if they are not available yet!", + "dependencyNotification": "With this plugin, the following dependencies will be installed if they are not available yet!", "dependencies": "Dependencies", "successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:", "reload": "reload now", - "restartNotification": "Restarting the scm-manager context, should only be done if no one else is currently working with it.", + "restartNotification": "You should only restart the scm-manager context if no one else is currently working with it.", "installPending": "The following plugins will be installed and after installation the scm-manager context will be restarted." } }, diff --git a/scm-ui/src/admin/plugins/components/waitForRestart.js b/scm-ui/src/admin/plugins/components/waitForRestart.js index 93b7b17855..a2f800a013 100644 --- a/scm-ui/src/admin/plugins/components/waitForRestart.js +++ b/scm-ui/src/admin/plugins/components/waitForRestart.js @@ -9,7 +9,7 @@ const waitForRestart = () => { // we need some initial delay if (!started) { started = true; - setTimeout(executor, 100, resolve, reject); + setTimeout(executor, 1000, resolve, reject); } else { apiClient .get("") diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index fa06740945..3ee4a4ea73 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -75,7 +75,7 @@ public class AvailablePluginResource { if (plugin.isPresent()) { return Response.ok(mapper.mapAvailable(plugin.get())).build(); } else { - throw notFound(entity(InstalledPluginDescriptor.class, name)); + throw notFound(entity("Plugin", name)); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index 893d09a51a..bc9d4b397c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -75,7 +75,7 @@ public class InstalledPluginResource { if (pluginDto.isPresent()) { return Response.ok(mapper.mapInstalled(pluginDto.get())).build(); } else { - throw notFound(entity(InstalledPluginDescriptor.class, name)); + throw notFound(entity("Plugin", name)); } } } From 2a184eaf17f385ebc87750b02f2c5c7f28387482 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer <rene.pfeuffer@cloudogu.com> Date: Thu, 22 Aug 2019 11:38:29 +0200 Subject: [PATCH 126/135] Export DiffFile ui component --- scm-ui-components/packages/ui-components/src/repos/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scm-ui-components/packages/ui-components/src/repos/index.js b/scm-ui-components/packages/ui-components/src/repos/index.js index 19f9a160b3..91cd2c0fb0 100644 --- a/scm-ui-components/packages/ui-components/src/repos/index.js +++ b/scm-ui-components/packages/ui-components/src/repos/index.js @@ -6,6 +6,7 @@ export { diffs }; export * from "./changesets"; export { default as Diff } from "./Diff"; +export { default as DiffFile } from "./DiffFile"; export { default as LoadingDiff } from "./LoadingDiff"; export type { From 58e5667e82b86192645dc90a219fe69a2142aa2a Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Tue, 27 Aug 2019 08:52:38 +0200 Subject: [PATCH 127/135] pluginprocessor only consider directories that contains plugin.xml --- .../src/main/java/sonia/scm/plugin/PluginProcessor.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 0613f4ef07..6f1b18034e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -59,6 +59,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import static java.util.stream.Collectors.toList; @@ -176,7 +177,7 @@ public final class PluginProcessor List<Path> dirs = collectPluginDirectories(pluginDirectory) .stream() - .filter(dir -> !dir.endsWith("sonia.scm.plugins")) + .filter(isPluginDirectory()) .collect(toList()); logger.debug("process {} directories: {}", dirs.size(), dirs); @@ -200,6 +201,10 @@ public final class PluginProcessor return ImmutableSet.copyOf(wrappers); } + private Predicate<Path> isPluginDirectory() { + return dir -> new File(dir.resolve("META-INF/scm/plugin.xml").toString()).exists(); + } + /** * Method description * From ca71326cb95107e5e49d1069aeaab95937c264cd Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Tue, 27 Aug 2019 11:07:19 +0200 Subject: [PATCH 128/135] redirect collection urls to add trailing slashes so that pagination works --- scm-ui/src/containers/Main.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 8c05578b6b..c6c5e30edb 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -1,16 +1,16 @@ //@flow import React from "react"; -import {Redirect, Route, Switch, withRouter} from "react-router-dom"; -import type {Links} from "@scm-manager/ui-types"; +import { Redirect, Route, Switch, withRouter } from "react-router-dom"; +import type { Links } from "@scm-manager/ui-types"; import Overview from "../repos/containers/Overview"; import Users from "../users/containers/Users"; import Login from "../containers/Login"; import Logout from "../containers/Logout"; -import {ProtectedRoute} from "@scm-manager/ui-components"; -import {binder, ExtensionPoint} from "@scm-manager/ui-extensions"; +import { ProtectedRoute } from "@scm-manager/ui-components"; +import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; import CreateUser from "../users/containers/CreateUser"; import SingleUser from "../users/containers/SingleUser"; @@ -34,7 +34,7 @@ class Main extends React.Component<Props> { render() { const { authenticated, links } = this.props; const redirectUrlFactory = binder.getExtension("main.redirect", this.props); - let url = "/repos"; + let url = "/repos/"; if (redirectUrlFactory) { url = redirectUrlFactory(this.props); } @@ -44,9 +44,10 @@ class Main extends React.Component<Props> { <Redirect exact from="/" to={url} /> <Route exact path="/login" component={Login} /> <Route path="/logout" component={Logout} /> + <Redirect exact strict from="/repos" to={url} /> <ProtectedRoute exact - path="/repos" + path="/repos/" component={Overview} authenticated={authenticated} /> @@ -67,9 +68,10 @@ class Main extends React.Component<Props> { component={RepositoryRoot} authenticated={authenticated} /> + <Redirect exact strict from="/users" to="/users/" /> <ProtectedRoute exact - path="/users" + path="/users/" component={Users} authenticated={authenticated} /> @@ -89,10 +91,10 @@ class Main extends React.Component<Props> { path="/user/:name" component={SingleUser} /> - + <Redirect exact strict from="/groups" to="/groups/" /> <ProtectedRoute exact - path="/groups" + path="/groups/" component={Groups} authenticated={authenticated} /> From c5ab0aaab34e00767fdbd7ae27d4306bed73adb6 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch <eduard.heimbuch@cloudogu.com> Date: Tue, 27 Aug 2019 11:09:53 +0200 Subject: [PATCH 129/135] fix repos redirect --- scm-ui/src/containers/Main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index c6c5e30edb..5e982652e8 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -44,7 +44,7 @@ class Main extends React.Component<Props> { <Redirect exact from="/" to={url} /> <Route exact path="/login" component={Login} /> <Route path="/logout" component={Logout} /> - <Redirect exact strict from="/repos" to={url} /> + <Redirect exact strict from="/repos" to="/repos/" /> <ProtectedRoute exact path="/repos/" From 840b44af378c194e613e0bafcfb0404fd4b837ef Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 27 Aug 2019 11:41:36 +0000 Subject: [PATCH 130/135] Close branch bugfix/redirect_collection_links From b36c2dd698f14d5e951e41870ca2dada2c6d8300 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 27 Aug 2019 13:59:14 +0200 Subject: [PATCH 131/135] use nio api and added test for plugin directory check --- .../main/java/sonia/scm/plugin/PluginProcessor.java | 3 ++- .../java/sonia/scm/plugin/PluginProcessorTest.java | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 6f1b18034e..5588349cc8 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -71,6 +71,7 @@ import static java.util.stream.Collectors.toList; * * TODO don't mix nio and io */ +@SuppressWarnings("squid:S3725") // performance is not critical, for this type of checks public final class PluginProcessor { @@ -202,7 +203,7 @@ public final class PluginProcessor } private Predicate<Path> isPluginDirectory() { - return dir -> new File(dir.resolve("META-INF/scm/plugin.xml").toString()).exists(); + return dir -> Files.exists(dir.resolve(DIRECTORY_METAINF).resolve("scm").resolve("plugin.xml")); } /** diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java index 87e9cbf7b7..e9eaea3afa 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java @@ -134,6 +134,16 @@ public class PluginProcessorTest assertThat(plugin.getId(), is(PLUGIN_A.id)); } + @Test + public void shouldCollectPluginsAndDoNotFailOnNonPluginDirectories() throws IOException { + new File(pluginDirectory, "some-directory").mkdirs(); + + copySmp(PLUGIN_A); + PluginWrapper plugin = collectAndGetFirst(); + + assertThat(plugin.getId(), is(PLUGIN_A.id)); + } + /** * Method description * From 3cd1cdfa4f6bf3efd002e40a31d8b7da4d9987eb Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 27 Aug 2019 12:30:25 +0000 Subject: [PATCH 132/135] Close branch bugfix/ignore_old_plugin_dir From c42b3cca0c281673e1804342110c83eb00ccc396 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 27 Aug 2019 13:16:39 +0000 Subject: [PATCH 133/135] Close branch bugfix/ci_status_validation From 79037afdf8a6ba91b070801b25eec0b5379fbdb8 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 27 Aug 2019 15:20:38 +0200 Subject: [PATCH 134/135] update maven-deploy-plugin from 2.7 to 2.8.2 --- pom.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3166c6ec75..3ab3cb82a3 100644 --- a/pom.xml +++ b/pom.xml @@ -439,6 +439,13 @@ <artifactId>smp-maven-plugin</artifactId> <version>1.0.0-alpha-6</version> </plugin> + + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-deploy-plugin</artifactId> + <version>2.8.2</version> + </plugin> + </plugins> </pluginManagement> @@ -633,7 +640,6 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-deploy-plugin</artifactId> - <version>2.7</version> </plugin> <plugin> From 769207c2c15af17b6a894333adf439700bdbd99b Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Date: Tue, 27 Aug 2019 15:33:30 +0200 Subject: [PATCH 135/135] fixed compilation error --- .../src/test/java/sonia/scm/plugin/PluginProcessorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java index dc5a9add68..bb518ec731 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java @@ -139,7 +139,7 @@ public class PluginProcessorTest new File(pluginDirectory, "some-directory").mkdirs(); copySmp(PLUGIN_A); - PluginWrapper plugin = collectAndGetFirst(); + InstalledPlugin plugin = collectAndGetFirst(); assertThat(plugin.getId(), is(PLUGIN_A.id)); }

-TdM%f<`we?~{IqXdzA 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 L0bC_!^zDBDZD?d` literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_img_neg.jpg b/docs/logo/scm-manager_logo_img_neg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1b56b5e6272e96242b1430355292f64cefb42402 GIT binary patch literal 25370 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 literal 0 HcmV?d00001 diff --git a/docs/logo/scm-manager_logo_img_neg.png b/docs/logo/scm-manager_logo_img_neg.png new file mode 100644 index 0000000000000000000000000000000000000000..796a02882c6ad5c0e01c7bed6a302ae324ab92d1 GIT binary patch literal 27443 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