diff --git a/.editorconfig b/.editorconfig index bcafffd..ef924dc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,9 @@ -[*.java] +root = true + +[{*.java,*.xml}] indent_style = tab indent_size = 4 + +[{*.css,*.js,*.soy,*.yml}] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index f706c22..bafd62c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .project .settings -.vscode +.classpath target tmp diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..de456f1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +dist: trusty +language: java +jdk: + - openjdk8 +before_install: + - wget https://marketplace.atlassian.com/download/plugins/atlassian-plugin-sdk-tgz + - mkdir opt + - tar -xvzf *plugin-sdk* -C opt + - mv opt/*plugin-sdk* opt/atlassian-plugin-sdk + - chmod a+x opt/atlassian-plugin-sdk/bin/* + - chmod a+x opt/atlassian-plugin-sdk/apache-maven-*/bin/* + - export PATH=opt/atlassian-plugin-sdk/bin:opt/atlassian-plugin-sdk/apache-maven-*/bin:$PATH + - atlas-version +install: + - atlas-mvn install -DskipTests=true +script: + - atlas-package diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f7527fc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic", + "maven.executable.path": "/usr/local/Cellar/atlassian-plugin-sdk/8.0.16/libexec/apache-maven-3.5.4/bin/mvn", + "java.configuration.maven.userSettings": "/usr/local/Cellar/atlassian-plugin-sdk/8.0.16/libexec/apache-maven-3.5.4/conf/settings.xml" +} diff --git a/README b/README.md similarity index 83% rename from README rename to README.md index f750230..f948985 100644 --- a/README +++ b/README.md @@ -20,3 +20,10 @@ Useful resources: * [SAL 3.0 Upgrade Guide](https://developer.atlassian.com/server/framework/atlassian-sdk/sal-3-0-upgrade-guide/) * [Atlassian Community - REST requests from a custom plugin](https://community.atlassian.com/t5/Answers-Developer-Questions/What-is-the-recommended-way-to-send-REST-requests-from-a-custom/qaq-p/568227) * [Atlassian AUI - Soy template components](https://bitbucket.org/atlassian/aui/src/6a8e37f85a07952cc290503f54d85ff5bebc8783/src/soy/?at=5.10.x) + +Code samples: + +* https://bitbucket.org/atlassian/stash-git-ops-plugin/src/master/ +* https://bitbucket.org/atlassianlabs/stash-refchange-settings-plugin/src/master/ +* https://bitbucket.org/atlassian/bitbucket-code-coverage/src/master/code-coverage-plugin/ +* https://bitbucket.org/atlassian/stash-auto-unapprove-plugin/src/master/ diff --git a/pom.xml b/pom.xml index 77c3f50..bf8992e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,198 +1,221 @@ - 4.0.0 - - com.recuencojones.bitbucket - log-on-clone - 1.0.0-SNAPSHOT - atlassian-plugin - - log-on-clone - This is the com.recuencojones.bitbucket:log-on-clone plugin for Atlassian Bitbucket Server. - - - Example Company - http://www.example.com/ - - - - true - 1.8 - 1.8 - UTF-8 - - 8.0.2 - 5.16.0 - ${bitbucket.version} - - - ${project.groupId}.${project.artifactId} - - 2.1.7 - 1 - 1.1.1 - 2.0.1 - - - - - - com.atlassian.bitbucket.server - bitbucket-parent - ${bitbucket.version} - pom - import - - - - - - com.atlassian.bitbucket.server - bitbucket-api - provided - - - com.atlassian.bitbucket.server - bitbucket-spi - provided - - - com.atlassian.soy - soy-template-renderer-api - provided - - - com.atlassian.plugins - atlassian-plugins-webresource - provided - - - com.atlassian.plugins.rest - atlassian-rest-common - provided - - - com.atlassian.plugins.rest - atlassian-rest-module - provided - - - com.atlassian.bitbucket.server - bitbucket-rest-model - provided - - - - com.atlassian.plugin - atlassian-spring-scanner-annotation - ${atlassian.spring.scanner.version} - provided - - - com.atlassian.sal - sal-api - provided - - - - com.google.code.gson - gson - provided - - - javax.inject - javax.inject - ${javax.inject.version} - provided - - - javax.servlet - javax.servlet-api - provided - - - javax.ws.rs - jsr311-api - ${jsr311.version} - provided - - - org.apache.commons - commons-lang3 - provided - - - - com.atlassian.plugins - atlassian-plugins-osgi-testrunner - ${plugin.testrunner.version} - test - - - junit - junit - test - - - - - - - com.atlassian.maven.plugins - bitbucket-maven-plugin - ${amps.version} - true - - - - bitbucket - bitbucket - ${bitbucket.version} - ${bitbucket.data.version} - - - - ${atlassian.plugin.key} - - - - com.recuencojones.bitbucket.log.api, - - - - - org.springframework.osgi.*;resolution:="optional", - org.eclipse.gemini.blueprint.*;resolution:="optional", - * - - - - * - - - - - com.atlassian.plugin - atlassian-spring-scanner-maven-plugin - ${atlassian.spring.scanner.version} - - - - atlassian-spring-scanner - - process-classes - - - - - - com.atlassian.plugin - atlassian-spring-scanner-external-jar - - - false - - - - + 4.0.0 + + com.recuencojones.bitbucket + log-on-clone + 1.0.0-SNAPSHOT + atlassian-plugin + + log-on-clone + This is the com.recuencojones.bitbucket:log-on-clone plugin for Atlassian Bitbucket Server. + + + Example Company + http://www.example.com/ + + + + true + 1.8 + 1.8 + UTF-8 + + 8.0.2 + 5.16.0 + ${bitbucket.version} + + + ${project.groupId}.${project.artifactId} + + 2.1.7 + 1 + 1.1.1 + 2.0.1 + + + + + + com.atlassian.bitbucket.server + bitbucket-parent + ${bitbucket.version} + pom + import + + + + + + com.atlassian.bitbucket.server + bitbucket-api + provided + + + com.atlassian.bitbucket.server + bitbucket-spi + provided + + + com.atlassian.activeobjects + activeobjects-plugin + provided + + + com.atlassian.soy + soy-template-renderer-api + provided + + + com.atlassian.plugins + atlassian-plugins-webresource + provided + + + com.atlassian.plugins.rest + atlassian-rest-common + provided + + + com.atlassian.plugins.rest + atlassian-rest-module + provided + + + com.atlassian.bitbucket.server + bitbucket-rest-model + provided + + + + org.springframework + spring-beans + provided + + + org.springframework + spring-context + provided + + + + com.atlassian.plugin + atlassian-spring-scanner-annotation + ${atlassian.spring.scanner.version} + provided + + + com.atlassian.sal + sal-api + provided + + + + com.google.code.gson + gson + provided + + + javax.inject + javax.inject + ${javax.inject.version} + provided + + + javax.servlet + javax.servlet-api + provided + + + javax.ws.rs + jsr311-api + ${jsr311.version} + provided + + + org.apache.commons + commons-lang3 + provided + + + + com.atlassian.plugins + atlassian-plugins-osgi-testrunner + ${plugin.testrunner.version} + test + + + junit + junit + test + + + org.mockito + mockito-core + test + + + + + + + com.atlassian.maven.plugins + bitbucket-maven-plugin + ${amps.version} + true + + + + bitbucket + bitbucket + ${bitbucket.version} + ${bitbucket.data.version} + + + + ${atlassian.plugin.key} + + + + com.recuencojones.bitbucket.log, + com.recuencojones.bitbucket.log.dao, + com.recuencojones.bitbucket.log.api, + + + + + org.springframework.osgi.*;resolution:="optional", + org.eclipse.gemini.blueprint.*;resolution:="optional", + * + + + + * + + + + + com.atlassian.plugin + atlassian-spring-scanner-maven-plugin + ${atlassian.spring.scanner.version} + + + + atlassian-spring-scanner + + process-classes + + + + + + com.atlassian.plugin + atlassian-spring-scanner-external-jar + + + false + + + + diff --git a/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryClone.java b/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryClone.java index 18843d6..f8a851a 100644 --- a/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryClone.java +++ b/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryClone.java @@ -1,57 +1,61 @@ package com.recuencojones.bitbucket.log; +import com.recuencojones.bitbucket.log.dao.*; + import com.atlassian.bitbucket.event.repository.RepositoryCloneEvent; import com.atlassian.bitbucket.repository.Repository; import com.atlassian.event.api.EventListener; import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; import com.atlassian.sal.api.net.*; -import com.atlassian.sal.api.pluginsettings.PluginSettings; -import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; import com.google.gson.Gson; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.inject.Named; import javax.inject.Inject; +import javax.inject.Named; -@Named("onRepositoryClone") +@Named public class OnRepositoryClone { private static final Logger log = LoggerFactory.getLogger(OnRepositoryClone.class); @ComponentImport - private final RequestFactory requestFactory; + private final RequestFactory requestFactory; - @ComponentImport - private final PluginSettingsFactory pluginSettingsFactory; + private final RepositoryCloneSettingsDAO repositoryCloneSettingsDAO; @Inject public OnRepositoryClone( - final RequestFactory requestFactory, - final PluginSettingsFactory pluginSettingsFactory + final RequestFactory requestFactory, + final RepositoryCloneSettingsDAO repositoryCloneSettingsDAO ) { this.requestFactory = requestFactory; - this.pluginSettingsFactory = pluginSettingsFactory; + this.repositoryCloneSettingsDAO = repositoryCloneSettingsDAO; } @EventListener - public void onCloneEvent(final RepositoryCloneEvent cloneEvent) { - final Repository repository = cloneEvent.getRepository(); - final String projectKey = repository.getProject().getKey(); + public void onCloneEvent(final RepositoryCloneEvent event) { + final Repository repository = event.getRepository(); + final int repositoryID = repository.getId(); final String repositorySlug = repository.getSlug(); + final String projectKey = repository.getProject().getKey(); + + final RepositoryCloneSettings settings = repositoryCloneSettingsDAO.get(repositoryID); - log.info("Repository {}/{} cloned", projectKey, repositorySlug); + if (settings != null && settings.isEnabled()) { + log.debug("Repository {}/{} has log-on-clone settings", projectKey, repositorySlug); - final Request request = requestFactory.createRequest(Request.MethodType.POST, "https://en6qhxx7a3ksl.x.pipedream.net"); + final Request request = requestFactory.createRequest(Request.MethodType.POST, settings.getURL()); - request.setRequestBody(new Gson().toJson(repository)); + request.setRequestBody(new Gson().toJson(repository)); - try { - request.execute(); - } catch (final ResponseException e) { - log.error("Could not log clone of {}/{}. Skipping.", projectKey, repositorySlug); + try { + request.execute(); + } catch (final ResponseException e) { + log.error("Could not log clone of {}/{}. Skipping.", projectKey, repositorySlug); + } } } } diff --git a/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryDeleted.java b/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryDeleted.java index a564186..910db8c 100644 --- a/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryDeleted.java +++ b/src/main/java/com/recuencojones/bitbucket/log/OnRepositoryDeleted.java @@ -1,42 +1,43 @@ package com.recuencojones.bitbucket.log; +import com.recuencojones.bitbucket.log.dao.*; + import com.atlassian.bitbucket.event.repository.RepositoryDeletedEvent; import com.atlassian.bitbucket.repository.Repository; import com.atlassian.event.api.EventListener; -import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; - -import com.atlassian.sal.api.pluginsettings.PluginSettings; -import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Named; import javax.inject.Inject; -@Named("onRepositoryDeleted") +@Named public class OnRepositoryDeleted { private static final Logger log = LoggerFactory.getLogger(OnRepositoryClone.class); - @ComponentImport - private final PluginSettingsFactory pluginSettingsFactory; + private final RepositoryCloneSettingsDAO repositoryCloneSettingsDAO; @Inject public OnRepositoryDeleted( - final PluginSettingsFactory pluginSettingsFactory + final RepositoryCloneSettingsDAO repositoryCloneSettingsDAO ) { - this.pluginSettingsFactory = pluginSettingsFactory; + this.repositoryCloneSettingsDAO = repositoryCloneSettingsDAO; } @EventListener public void onRepositoryDeleted(final RepositoryDeletedEvent event) { - deleteSettings(event.getRepository()); - } - - private void deleteSettings(final Repository repository) { - + final Repository repository = event.getRepository(); + final int repositoryID = repository.getId(); + final String repositorySlug = repository.getSlug(); + final String projectKey = repository.getProject().getKey(); + final RepositoryCloneSettings settings = repositoryCloneSettingsDAO.get(repositoryID); + + if (settings != null) { + log.debug("Repository {}/{} has log-on-clone settings, delete.", projectKey, repositorySlug); + repositoryCloneSettingsDAO.remove(settings); + } } } diff --git a/src/main/java/com/recuencojones/bitbucket/log/RepositoryCloneSettingsServlet.java b/src/main/java/com/recuencojones/bitbucket/log/RepositoryCloneSettingsServlet.java index f28d84c..1042031 100644 --- a/src/main/java/com/recuencojones/bitbucket/log/RepositoryCloneSettingsServlet.java +++ b/src/main/java/com/recuencojones/bitbucket/log/RepositoryCloneSettingsServlet.java @@ -5,9 +5,6 @@ import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; -import com.atlassian.sal.api.pluginsettings.PluginSettings; -import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; - import com.atlassian.soy.renderer.SoyException; import com.atlassian.soy.renderer.SoyTemplateRenderer; import com.atlassian.webresource.api.assembler.PageBuilderService; @@ -15,9 +12,6 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import javax.inject.Inject; import javax.servlet.ServletException; @@ -29,11 +23,9 @@ import java.util.Map; -class RepositoryCloneSettingsServlet extends HttpServlet { - private static final Logger log = LoggerFactory.getLogger(RepositoryCloneSettingsServlet.class); +public class RepositoryCloneSettingsServlet extends HttpServlet { - @ComponentImport - private final PluginSettingsFactory pluginSettingsFactory; + private static final long serialVersionUID = 1L; @ComponentImport private final RepositoryService repositoryService; @@ -46,12 +38,10 @@ class RepositoryCloneSettingsServlet extends HttpServlet { @Inject public RepositoryCloneSettingsServlet( - final PluginSettingsFactory pluginSettingsFactory, final RepositoryService repositoryService, final SoyTemplateRenderer soyTemplateRenderer, final PageBuilderService pageBuilderService ) { - this.pluginSettingsFactory = pluginSettingsFactory; this.repositoryService = repositoryService; this.soyTemplateRenderer = soyTemplateRenderer; this.pageBuilderService = pageBuilderService; diff --git a/src/main/java/com/recuencojones/bitbucket/log/dao/RepositoryCloneSettings.java b/src/main/java/com/recuencojones/bitbucket/log/dao/RepositoryCloneSettings.java new file mode 100644 index 0000000..db240ff --- /dev/null +++ b/src/main/java/com/recuencojones/bitbucket/log/dao/RepositoryCloneSettings.java @@ -0,0 +1,37 @@ +package com.recuencojones.bitbucket.log.dao; + +import net.java.ao.Accessor; +import net.java.ao.Entity; +import net.java.ao.Mutator; +import net.java.ao.schema.Indexed; +import net.java.ao.schema.NotNull; +import net.java.ao.schema.StringLength; +import net.java.ao.schema.Table; + +import static net.java.ao.schema.StringLength.UNLIMITED; + +@Table("RC_SETTINGS") +public interface RepositoryCloneSettings extends Entity { + + String REPO_ID_COLUMN = "REPO_ID"; + String ENABLED_COLUMN = "ENABLED"; + String URL_COLUMN = "URL"; + + @Indexed + @Accessor(REPO_ID_COLUMN) + @NotNull + int getId(); + + @Accessor(ENABLED_COLUMN) + boolean isEnabled(); + + @Mutator(ENABLED_COLUMN) + void setEnabled(boolean enabled); + + @Accessor(URL_COLUMN) + @StringLength(UNLIMITED) + String getURL(); + + @Mutator(URL_COLUMN) + void setURL(String url); +} diff --git a/src/main/java/com/recuencojones/bitbucket/log/dao/RepositoryCloneSettingsDAO.java b/src/main/java/com/recuencojones/bitbucket/log/dao/RepositoryCloneSettingsDAO.java new file mode 100644 index 0000000..5f10a3d --- /dev/null +++ b/src/main/java/com/recuencojones/bitbucket/log/dao/RepositoryCloneSettingsDAO.java @@ -0,0 +1,68 @@ +package com.recuencojones.bitbucket.log.dao; + +import com.atlassian.activeobjects.external.ActiveObjects; + +import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; +import org.springframework.stereotype.Component; + +import net.java.ao.DBParam; + +import javax.inject.Inject; + +import static com.recuencojones.bitbucket.log.dao.RepositoryCloneSettings.REPO_ID_COLUMN; + +@Component +public class RepositoryCloneSettingsDAO { + private static final String ID_QUERY = String.format("%s = ?", REPO_ID_COLUMN); + + @ComponentImport + private final ActiveObjects ao; + + @Inject + public RepositoryCloneSettingsDAO( + ActiveObjects ao + ) { + this.ao = ao; + } + + public RepositoryCloneSettings save(int repositoryID, String url, boolean enabled) { + RepositoryCloneSettings settings = find(repositoryID); + + if (settings == null) { + settings = ao.create( + RepositoryCloneSettings.class, + new DBParam(REPO_ID_COLUMN, repositoryID) + ); + } + + settings.setURL(url); + settings.setEnabled(enabled); + settings.save(); + + return settings; + } + + public RepositoryCloneSettings get(int repositoryID) { + return find(repositoryID); + } + + public void remove(int repositoryID) { + RepositoryCloneSettings settings = find(repositoryID); + + remove(settings); + } + + public void remove(RepositoryCloneSettings settings) { + ao.delete(settings); + } + + private RepositoryCloneSettings find(int repositoryID) { + RepositoryCloneSettings[] results = ao.find(RepositoryCloneSettings.class, ID_QUERY, repositoryID); + + if (results.length == 0) { + return null; + } + + return results[0]; + } +} diff --git a/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsDTO.java b/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsDTO.java new file mode 100644 index 0000000..24ed133 --- /dev/null +++ b/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsDTO.java @@ -0,0 +1,30 @@ +package com.recuencojones.bitbucket.log.rest; + +import org.codehaus.jackson.annotate.JsonIgnoreProperties; +import org.codehaus.jackson.annotate.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class RepositoryCloneSettingsDTO { + + @JsonProperty("enabled") + private boolean enabled; + + @JsonProperty("url") + private String url; + + public boolean getEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getURL() { + return url; + } + + public void setURL(String url) { + this.url = url; + } +} diff --git a/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResource.java b/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResource.java index 1231324..a03bbc7 100644 --- a/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResource.java +++ b/src/main/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResource.java @@ -1,5 +1,7 @@ package com.recuencojones.bitbucket.log.rest; +import com.recuencojones.bitbucket.log.dao.*; + import com.atlassian.bitbucket.repository.Repository; import com.atlassian.bitbucket.rest.util.ResourcePatterns; import com.atlassian.bitbucket.rest.util.ResponseFactory; @@ -34,26 +36,41 @@ public class RepositoryCloneSettingsResource { @ComponentImport private final PermissionValidationService permissionValidationService; + private final RepositoryCloneSettingsDAO repositoryCloneSettingsDAO; + @Inject public RepositoryCloneSettingsResource( - PermissionValidationService permissionValidationService + PermissionValidationService permissionValidationService, + RepositoryCloneSettingsDAO repositoryCloneSettingsDAO ) { this.permissionValidationService = permissionValidationService; + this.repositoryCloneSettingsDAO = repositoryCloneSettingsDAO; } @GET public Response getSettings(@Context Repository repository) { log.info("Retrieve logging url for repository {}/{}", repository.getProject().getKey(), repository.getSlug()); - // return ResponseFactory.ok(new RestAutoUnapproveSettings(unapproveService.getSettings(scope))).build(); + RepositoryCloneSettings settings = repositoryCloneSettingsDAO.get(repository.getId()); + + if (settings != null) { + RepositoryCloneSettingsDTO settingsDTO = new RepositoryCloneSettingsDTO(); + + settingsDTO.setEnabled(settings.isEnabled()); + settingsDTO.setURL(settings.getURL()); + + return ResponseFactory.ok(settingsDTO).build(); + } + return ResponseFactory.ok().build(); } @POST - public Response saveSettings(@Context Repository repository) { + public Response saveSettings(@Context Repository repository, RepositoryCloneSettingsDTO settings) { permissionValidationService.validateForRepository(repository, Permission.REPO_ADMIN); log.info("Store logging url for repository {}/{}", repository.getProject().getKey(), repository.getSlug()); + repositoryCloneSettingsDAO.save(repository.getId(), settings.getURL(), settings.getEnabled()); return ResponseFactory.ok().build(); } diff --git a/src/main/resources/META-INF/spring/plugin-context.xml b/src/main/resources/META-INF/spring/plugin-context.xml index b1f0519..0caa4e8 100644 --- a/src/main/resources/META-INF/spring/plugin-context.xml +++ b/src/main/resources/META-INF/spring/plugin-context.xml @@ -1,10 +1,10 @@ - - \ No newline at end of file + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns:atlassian-scanner="http://www.atlassian.com/schema/atlassian-scanner" + xsi:schemaLocation="http://www.springframework.org/schema/beans + http://www.springframework.org/schema/beans/spring-beans-2.5.xsd + http://www.atlassian.com/schema/atlassian-scanner + http://www.atlassian.com/schema/atlassian-scanner/atlassian-scanner.xsd"> + + diff --git a/src/main/resources/atlassian-plugin.xml b/src/main/resources/atlassian-plugin.xml index 3182ad5..ee4d916 100644 --- a/src/main/resources/atlassian-plugin.xml +++ b/src/main/resources/atlassian-plugin.xml @@ -1,51 +1,56 @@ - - ${project.description} - ${project.version} - - true - images/pluginIcon.png - images/pluginLogo.png - + + ${project.description} + ${project.version} + + true + images/pluginIcon.png + images/pluginLogo.png + - - + + - - - Log clone events to a remote service - ${navBuilder.pluginServlets().path('log-on-clone', $project.key, $repository.slug).buildRelNoContext()} - + + + Log clone events to a remote service + ${navBuilder.pluginServlets().path('log-on-clone', $project.key, $repository.slug).buildRelNoContext()} + - - /log-on-clone/* - + + /log-on-clone/* + - - REQUEST - FORWARD - - extension.filter.excludes - .* - - com.recuencojones.bitbucket.log.rest - + + REQUEST + FORWARD + + extension.filter.excludes + .* + + com.recuencojones.bitbucket.log.rest + - - - /**/*.soy - - com.atlassian.bitbucket.server.bitbucket-web:server-soy-templates - + + The module configuring the Active Objects service used by this plugin + com.recuencojones.bitbucket.log.dao.RepositoryCloneSettings + - - - /**/*.soy - - bitbucket.page.repository.settings.log-on-clone - com.atlassian.bitbucket.server.bitbucket-web-api:navbuilder - com.atlassian.bitbucket.server.bitbucket-web-api:server - com.atlassian.bitbucket.server.bitbucket-web:global - com.atlassian.auiplugin:aui-experimental-spinner - + + + /**/*.soy + + com.atlassian.bitbucket.server.bitbucket-web:server-soy-templates + + + + + /**/*.soy + + bitbucket.page.repository.settings.log-on-clone + com.atlassian.bitbucket.server.bitbucket-web-api:navbuilder + com.atlassian.bitbucket.server.bitbucket-web-api:server + com.atlassian.bitbucket.server.bitbucket-web:global + com.atlassian.auiplugin:aui-experimental-spinner + diff --git a/src/main/resources/log-on-clone.properties b/src/main/resources/log-on-clone.properties index fe8d29c..65c4f3c 100644 --- a/src/main/resources/log-on-clone.properties +++ b/src/main/resources/log-on-clone.properties @@ -1,5 +1,7 @@ bitbucket.web.repository.log-on-clone.title=Log on clone for {0} / {1} bitbucket.web.repository.log-on-clone.heading=Log on clone +bitbucket.web.repository.log-on-clone.setting.enabled.label=Enable +bitbucket.web.repository.log-on-clone.setting.enabled.description=Call URL on clone event? bitbucket.web.repository.log-on-clone.setting.url.label=URL bitbucket.web.repository.log-on-clone.setting.url.placeholder=Logging service URL bitbucket.web.repository.log-on-clone.setting.url.description=Target url should be a webhook listening on http/https. Event will be sent with POST method. diff --git a/src/main/resources/static/clone-log-settings.css b/src/main/resources/static/clone-log-settings.css index 745dc00..69a3c38 100644 --- a/src/main/resources/static/clone-log-settings.css +++ b/src/main/resources/static/clone-log-settings.css @@ -6,3 +6,10 @@ width: 20px; top: 5px; } + +.button-success { + display: inline-block; + top: -12px; + margin-left: 8px; + color: #35B37E; +} diff --git a/src/main/resources/static/clone-log-settings.js b/src/main/resources/static/clone-log-settings.js index f575100..12fcbe2 100644 --- a/src/main/resources/static/clone-log-settings.js +++ b/src/main/resources/static/clone-log-settings.js @@ -1,11 +1,9 @@ define('plugin/log-on-clone/repository-settings', [ 'jquery', - 'aui', 'bitbucket/util/server', 'bitbucket/util/navbuilder', - 'bitbucket/util/state', 'exports' -], function($, AJS, server, nav, pageState, exports) { +], function($, server, nav, exports) { function resourceUrl(resourceName) { return nav .rest('log-on-clone') @@ -15,14 +13,18 @@ define('plugin/log-on-clone/repository-settings', [ } var formSelector = '#log-on-clone-settings-form'; - var inputSelector = 'input#url'; + var urlInputSelector = 'input#url'; + var enabledInputSelector = 'input#enabled'; + var successTemplate = 'Done!'; var spinnerTemplate = '
'; function init() { var $form = $(formSelector); - var $input = $(inputSelector); + var $urlInput = $(urlInputSelector); + var $enabledInput = $(enabledInputSelector); var $submit = $form.find('#log-on-clone-settings-form-submit'); var $spinner; + var $success; function addSpinner() { $spinner = $(spinnerTemplate).insertAfter($submit); @@ -34,65 +36,81 @@ define('plugin/log-on-clone/repository-settings', [ $spinner = null; } - function setInputEnabled(enabled) { - if (enabled) { - $input.removeAttr('disabled').removeClass('disabled'); - } else { - $input.attr('disabled', 'disabled').addClass('disabled'); - } + function flashSuccess() { + $success = $(successTemplate).insertAfter($submit); + setTimeout(function() { + $success && $success.remove(); + $success = null; + }, 1000); } - function setSubmitEnabled(enabled) { + function setEnabled($el, enabled) { if (enabled) { - $submit.removeAttr('disabled').removeClass('disabled'); + $el.removeAttr('disabled').removeClass('disabled'); } else { - $submit.attr('disabled', 'disabled').addClass('disabled'); + $el.attr('disabled', 'disabled').addClass('disabled'); } } - function setValue(value) { - $input.val(value); + function setUrlValue(value) { + $urlInput.val(value); + } + + function setEnabledValue(value) { + $enabledInput.prop('checked', value); } $form.submit(function(event) { event.preventDefault(); addSpinner(); - setInputEnabled(false); - setSubmitEnabled(false); + setEnabled($urlInput, false); + setEnabled($enabledInput, false); + setEnabled($submit, false); server .rest({ url: resourceUrl('settings'), type: 'POST', data: { - url: $input.val() + url: $urlInput.val(), + enabled: $enabledInput.prop('checked') } }) .always(function() { removeSpinner(); - setInputEnabled(true); - setSubmitEnabled(true); + flashSuccess(); + setEnabled($urlInput, true); + setEnabled($enabledInput, true); + setEnabled($submit, true); }); }); - setInputEnabled(false); - setSubmitEnabled(false); + addSpinner(); + setEnabled($urlInput, false); + setEnabled($enabledInput, false); + setEnabled($submit, false); server .rest({ url: resourceUrl('settings'), type: 'GET' }) - .then(function() { - setInputEnabled(true); - setSubmitEnabled(true); - setValue('https://en6qhxx7a3ksl.x.pipedream.net'); + .then(function(response) { + if (response) { + setUrlValue(response.url); + setEnabledValue(response.enabled); + } + + removeSpinner(); + setEnabled($urlInput, true); + setEnabled($enabledInput, true); + setEnabled($submit, true); }); } exports.onReady = function() { - $(document).ready(() => { + $(document).ready(function() { init(); }); }; diff --git a/src/main/resources/static/clone-log-settings.soy b/src/main/resources/static/clone-log-settings.soy index 1abbe08..551b9c4 100644 --- a/src/main/resources/static/clone-log-settings.soy +++ b/src/main/resources/static/clone-log-settings.soy @@ -50,6 +50,22 @@ * Actual settings form */ {template .settingsContent} + {call aui.form.fieldGroup} + {param content} + {call aui.form.label} + {param forField: 'enabled' /} + {param content: getText('bitbucket.web.repository.log-on-clone.setting.enabled.label') /} + {/call} + {call aui.form.input} + {param id: 'enabled' /} + {param name: 'enabled' /} + {param type: 'checkbox' /} + {/call} + {call aui.form.fieldDescription} + {param text: getText('bitbucket.web.repository.log-on-clone.setting.enabled.description') /} + {/call} + {/param} + {/call} {call aui.form.fieldGroup} {param content} {call aui.form.label} @@ -64,6 +80,7 @@ {param placeholderText: getText('bitbucket.web.repository.log-on-clone.setting.url.placeholder') /} {param extraAttributes} pattern="https?://.*" + required {/param} {/call} {call aui.form.fieldDescription} diff --git a/src/test/java/com/recuencojones/bitbucket/log/OnRepositoryCloneTest.java b/src/test/java/com/recuencojones/bitbucket/log/OnRepositoryCloneTest.java new file mode 100644 index 0000000..ef5e271 --- /dev/null +++ b/src/test/java/com/recuencojones/bitbucket/log/OnRepositoryCloneTest.java @@ -0,0 +1,88 @@ +package com.recuencojones.bitbucket.log; + +import com.atlassian.bitbucket.event.repository.RepositoryCloneEvent; +import com.atlassian.bitbucket.project.Project; +import com.atlassian.bitbucket.repository.Repository; +import com.atlassian.sal.api.net.Request; +import com.atlassian.sal.api.net.RequestFactory; +import com.atlassian.sal.api.net.ResponseException; +import com.recuencojones.bitbucket.log.dao.RepositoryCloneSettings; +import com.recuencojones.bitbucket.log.dao.RepositoryCloneSettingsDAO; + +import org.junit.Before; +import org.junit.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class OnRepositoryCloneTest { + final String url = "https://url"; + final String projectKey = "proj_1"; + final String repositorySlug = "repo_1"; + final int repositoryID = 1337; + + private Request mockRequest; + private RequestFactory mockRequestFactory; + private RepositoryCloneSettings mockRepositoryCloneSettings; + private RepositoryCloneSettingsDAO mockRepositoryCloneSettingsDAO; + private RepositoryCloneEvent mockEvent; + private Repository mockRepository; + private Project mockProject; + + @Before + public void setup() { + mockRequest = mock(Request.class); + mockRequestFactory = mock(RequestFactory.class); + mockRepositoryCloneSettings = mock(RepositoryCloneSettings.class); + mockRepositoryCloneSettingsDAO = mock(RepositoryCloneSettingsDAO.class); + mockEvent = mock(RepositoryCloneEvent.class); + mockRepository = mock(Repository.class); + mockProject = mock(Project.class); + + when(mockProject.getKey()).thenReturn(projectKey); + when(mockRepository.getId()).thenReturn(repositoryID); + when(mockRepository.getSlug()).thenReturn(repositorySlug); + when(mockRepository.getProject()).thenReturn(mockProject); + when(mockEvent.getRepository()).thenReturn(mockRepository); + when(mockRequestFactory.createRequest(Request.MethodType.POST, url)).thenReturn(mockRequest); + } + + @Test + public void testCloneRepositoryWithSettingsShouldExecutePostRequestWhenEnabled() throws ResponseException { + final OnRepositoryClone component = new OnRepositoryClone(mockRequestFactory, mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(mockRepositoryCloneSettings); + when(mockRepositoryCloneSettings.getURL()).thenReturn(url); + when(mockRepositoryCloneSettings.isEnabled()).thenReturn(true); + + component.onCloneEvent(mockEvent); + + verify(mockRequest).execute(); + } + + @Test + public void testCloneRepositoryWithSettingsShouldNotExecutePostRequestWhenNotEnabled() throws ResponseException { + final OnRepositoryClone component = new OnRepositoryClone(mockRequestFactory, mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(mockRepositoryCloneSettings); + when(mockRepositoryCloneSettings.getURL()).thenReturn(url); + when(mockRepositoryCloneSettings.isEnabled()).thenReturn(false); + + component.onCloneEvent(mockEvent); + + verify(mockRequest, never()).execute(); + } + + @Test + public void testCloneRepositoryWithoutSettingsShouldNotExecutePostRequest() throws ResponseException { + final OnRepositoryClone component = new OnRepositoryClone(mockRequestFactory, mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(null); + + component.onCloneEvent(mockEvent); + + verify(mockRequest, never()).execute(); + } +} diff --git a/src/test/java/com/recuencojones/bitbucket/log/OnRepositoryDeletedTest.java b/src/test/java/com/recuencojones/bitbucket/log/OnRepositoryDeletedTest.java new file mode 100644 index 0000000..3b90213 --- /dev/null +++ b/src/test/java/com/recuencojones/bitbucket/log/OnRepositoryDeletedTest.java @@ -0,0 +1,64 @@ +package com.recuencojones.bitbucket.log; + +import com.atlassian.bitbucket.event.repository.RepositoryDeletedEvent; +import com.atlassian.bitbucket.project.Project; +import com.atlassian.bitbucket.repository.Repository; +import com.recuencojones.bitbucket.log.dao.RepositoryCloneSettings; +import com.recuencojones.bitbucket.log.dao.RepositoryCloneSettingsDAO; + +import org.junit.Before; +import org.junit.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class OnRepositoryDeletedTest { + final String projectKey = "proj_1"; + final String repositorySlug = "repo_1"; + final int repositoryID = 1337; + + private RepositoryCloneSettings mockRepositoryCloneSettings; + private RepositoryCloneSettingsDAO mockRepositoryCloneSettingsDAO; + private RepositoryDeletedEvent mockEvent; + private Repository mockRepository; + private Project mockProject; + + @Before + public void setup() { + mockRepositoryCloneSettings = mock(RepositoryCloneSettings.class); + mockRepositoryCloneSettingsDAO = mock(RepositoryCloneSettingsDAO.class); + mockEvent = mock(RepositoryDeletedEvent.class); + mockRepository = mock(Repository.class); + mockProject = mock(Project.class); + + when(mockProject.getKey()).thenReturn(projectKey); + when(mockRepository.getId()).thenReturn(repositoryID); + when(mockRepository.getSlug()).thenReturn(repositorySlug); + when(mockRepository.getProject()).thenReturn(mockProject); + when(mockEvent.getRepository()).thenReturn(mockRepository); + } + + @Test + public void testDeleteRepositoryWithSettingsShouldRemoveSettings() { + final OnRepositoryDeleted component = new OnRepositoryDeleted(mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(mockRepositoryCloneSettings); + + component.onRepositoryDeleted(mockEvent); + + verify(mockRepositoryCloneSettingsDAO).remove(mockRepositoryCloneSettings); + } + + @Test + public void testDeleteRepositoryWithoutSettingsShouldNotRemoveSettings() { + final OnRepositoryDeleted component = new OnRepositoryDeleted(mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(null); + + component.onRepositoryDeleted(mockEvent); + + verify(mockRepositoryCloneSettingsDAO, never()).remove(mockRepositoryCloneSettings); + } +} diff --git a/src/test/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResourceTest.java b/src/test/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResourceTest.java new file mode 100644 index 0000000..a7f6089 --- /dev/null +++ b/src/test/java/com/recuencojones/bitbucket/log/rest/RepositoryCloneSettingsResourceTest.java @@ -0,0 +1,97 @@ +package com.recuencojones.bitbucket.log.rest; + +import com.atlassian.bitbucket.AuthorisationException; +import com.atlassian.bitbucket.permission.Permission; +import com.atlassian.bitbucket.permission.PermissionValidationService; +import com.atlassian.bitbucket.project.Project; +import com.atlassian.bitbucket.repository.Repository; +import com.recuencojones.bitbucket.log.dao.RepositoryCloneSettings; +import com.recuencojones.bitbucket.log.dao.RepositoryCloneSettingsDAO; + +import javax.ws.rs.core.Response; + +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class RepositoryCloneSettingsResourceTest { + final boolean enabled = true; + final String url = "https://url"; + final String projectKey = "proj_1"; + final String repositorySlug = "repo_1"; + final int repositoryID = 1337; + + private PermissionValidationService mockPermissionValidationService; + private RepositoryCloneSettings mockRepositoryCloneSettings; + private RepositoryCloneSettingsDAO mockRepositoryCloneSettingsDAO; + private Repository mockRepository; + private Project mockProject; + + @Before + public void setup() { + mockPermissionValidationService = mock(PermissionValidationService.class); + mockRepositoryCloneSettings = mock(RepositoryCloneSettings.class); + mockRepositoryCloneSettingsDAO = mock(RepositoryCloneSettingsDAO.class); + mockRepository = mock(Repository.class); + mockProject = mock(Project.class); + + when(mockProject.getKey()).thenReturn(projectKey); + when(mockRepository.getId()).thenReturn(repositoryID); + when(mockRepository.getSlug()).thenReturn(repositorySlug); + when(mockRepository.getProject()).thenReturn(mockProject); + } + + @Test + public void testGetSettingsForRepositoryWithConfigShouldReturnSettingsObject() { + RepositoryCloneSettingsResource component = new RepositoryCloneSettingsResource(mockPermissionValidationService, mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(mockRepositoryCloneSettings); + + Response res = component.getSettings(mockRepository); + + assertThat(res.getEntity(), instanceOf(RepositoryCloneSettingsDTO.class)); + } + + @Test + public void testGetSettingsForRepositoryWithoutConfigShouldReturnNull() { + RepositoryCloneSettingsResource component = new RepositoryCloneSettingsResource(mockPermissionValidationService, mockRepositoryCloneSettingsDAO); + + when(mockRepositoryCloneSettingsDAO.get(repositoryID)).thenReturn(null); + + Response res = component.getSettings(mockRepository); + + assertNull(res.getEntity()); + } + + @Test + public void testSaveSettingsForRepositoryAdminShouldSucceed() { + RepositoryCloneSettingsResource component = new RepositoryCloneSettingsResource(mockPermissionValidationService, mockRepositoryCloneSettingsDAO); + RepositoryCloneSettingsDTO settings = new RepositoryCloneSettingsDTO(); + + settings.setEnabled(enabled); + settings.setURL(url); + + component.saveSettings(mockRepository, settings); + + verify(mockRepositoryCloneSettingsDAO).save(repositoryID, url, enabled); + } + + @Test(expected = AuthorisationException.class) + public void testSaveSettingsForNonRepositoryAdminShouldFail() { + RepositoryCloneSettingsResource component = new RepositoryCloneSettingsResource(mockPermissionValidationService, mockRepositoryCloneSettingsDAO); + RepositoryCloneSettingsDTO settings = new RepositoryCloneSettingsDTO(); + + doThrow(AuthorisationException.class) + .when(mockPermissionValidationService) + .validateForRepository(mockRepository, Permission.REPO_ADMIN); + + component.saveSettings(mockRepository, settings); + } +}