Add support for the Storyboard issue tracking system integration This implements the ability to add comments to storybaord task tracking system[1][2]. Updating of story status will be implemented in a follow on change. [1] https://wiki.openstack.org/wiki/StoryBoard [2] http://git.openstack.org/cgit/openstack-infra/storyboard Change-Id: If25ec21d4b89ef82dfef3d5f1f2dec1cc8294005
diff --git a/BUCK b/BUCK index 7853f51..0a40036 100644 --- a/BUCK +++ b/BUCK
@@ -4,13 +4,100 @@ resources = glob(['src/main/resources/**/*']), manifest_entries = [ 'Gerrit-PluginName: its-storyboard', - 'Gerrit-Module: com.googlesource.gerrit.plugins.hooks.sb.InitStoryboard', - 'Gerrit-InitStep: com.googlesource.gerrit.plugins.hooks.sb.StoryboardModule', + 'Gerrit-Module: com.googlesource.gerrit.plugins.its.storyboard.StoryboardModule', 'Gerrit-ReloadMode: reload', - 'Implementation-Title: Plugin its-storyboard', - 'Implementation-URL: https://www.openstack.org', + 'Gerrit-ApiType: plugin', + 'Implementation-Vendor: Hewlett Packard', + 'Implementation-Title: its-storyboard plugin', + 'Implementation-URL: https://gerrit.googlesource.com/plugins/its-storyboard', ], deps = [ - '//plugins/its-base:its-base__plugin', + ':its-base_stripped', + '//lib/httpcomponents:httpcore', + '//lib/httpcomponents:httpclient', + '//lib:gson', + ], +) + + +def strip_jar( + name, + src, + excludes = [], + visibility = [], + ): + name_zip = name + '.zip' + genrule( + name = name_zip, + cmd = 'cp $SRCS $OUT && zip -qd $OUT ' + ' '.join(excludes), + srcs = [ src ], + deps = [ src ], + out = name_zip, + visibility = visibility, + ) + prebuilt_jar( + name = name, + binary_jar = ':' + name_zip, + visibility = visibility, + ) + +strip_jar( + name = 'its-base_stripped', + src = '//plugins/its-base:its-base', + excludes = [ + 'Documentation/about.md', + 'Documentation/build.md', + 'Documentation/config-connectivity.md', + 'Documentation/config-rulebase-plugin-actions.md', + ] +) + +TEST_UTIL_SRC = glob(['src/test/java/com/googlesource/gerrit/plugins/its/testutil/**/*.java']) + +java_library( + name = 'its-storyboard_tests-utils', + srcs = TEST_UTIL_SRC, + deps = [ + '//lib:guava', + '//plugins/its-storyboard/lib:easymock', + '//lib/log:impl_log4j', + '//lib/log:log4j', + '//lib:junit', + '//plugins/its-storyboard/lib:powermock-api-easymock', + '//lib/powermock:powermock-api-support', + '//lib/powermock:powermock-core', + '//lib/powermock:powermock-module-junit4', + '//lib/powermock:powermock-module-junit4-common', + ], +) + +java_test( + name = 'its-storyboard_tests', + srcs = glob( + ['src/test/java/**/*.java'], + excludes = TEST_UTIL_SRC + ), + labels = ['its-storyboard'], + source_under_test = [':its-storyboard__plugin'], + deps = [ + ':its-storyboard__plugin', + ':its-storyboard_tests-utils', + '//gerrit-plugin-api:lib', + '//plugins/its-storyboard/lib:easymock', + '//lib:guava', + '//lib/guice:guice', + '//lib/jgit:jgit', + '//lib:junit', + '//lib/log:api', + '//lib/log:impl_log4j', + '//lib/log:log4j', + '//plugins/its-storyboard/lib:powermock-api-easymock', + '//lib/powermock:powermock-api-support', + '//lib/powermock:powermock-core', + '//lib/powermock:powermock-module-junit4', + '//lib/powermock:powermock-module-junit4-common', + '//lib/powermock:powermock-reflect', + '//lib/httpcomponents:httpclient', + '//lib:gson', ], )
diff --git a/lib/BUCK b/lib/BUCK index 77e73bc..fb07316 100644 --- a/lib/BUCK +++ b/lib/BUCK
@@ -1,35 +1,38 @@ include_defs('//lib/maven.defs') +# Upstream is at 3.2, which does not work with PowerMock 1.5's expectNew. +# So we force EasyMock 3.1. maven_jar( - name = 'xmlrpc-client', - id = 'org.apache.xmlrpc:xmlrpc-client:3.1.3', - sha1 = 'e486ad917028b52265610206fb5a1e2b5914b94b', - license = 'Apache2.0', - deps = [':xmlrpc-common'], - visibility = [], + name = 'easymock', + id = 'org.easymock:easymock:3.1', + sha1 = '3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e', + license = 'DO_NOT_DISTRIBUTE', + deps = [ + '//lib/easymock:cglib-2_2', + ':objenesis', + ], ) +# Duplicate upstream's objenesis, which would not be +# visible otherwise. maven_jar( - name = 'xmlrpc-common', - id = 'org.apache.xmlrpc:xmlrpc-common:3.1.3', - sha1 = '415daf1f1473a947452588906dc9f5b3575fb44d', - license = 'Apache2.0', - deps = [':ws-commons-util'], - visibility = [], + name = 'objenesis', + id = 'org.objenesis:objenesis:1.2', + sha1 = 'bfcb0539a071a4c5a30690388903ac48c0667f2a', + license = 'DO_NOT_DISTRIBUTE', + visibility = ['//lib/powermock:powermock-reflect'], + attach_source = False, ) +# Upstream's powermock-api-easymock would pull in upstream's +# EasyMock 3.2, so we hard-wire dependency to the plugin's EasyMock. maven_jar( - name = 'ws-commons-util', - id = 'org.apache.ws.commons.util:ws-commons-util:1.0.2', - sha1 = '3f478e6def772c19d1053f61198fa1f6a6119238', - license = 'Apache2.0', - deps = [':xml-apis'], - visibility = [], -) - -maven_jar( - name = 'xml-apis', - id = 'xml-apis:xml-apis:1.0.b2', - sha1 = '3136ca936f64c9d68529f048c2618bd356bf85c9', - license = 'Apache2.0', -) + name = 'powermock-api-easymock', + id = 'org.powermock:powermock-api-easymock:1.5', + sha1 = 'a485b570b9debb46b53459a8e866a40343b2cfe2', + license = 'DO_NOT_DISTRIBUTE', + deps = [ + '//lib/powermock:powermock-api-support', + ':easymock', + ], +) \ No newline at end of file
diff --git a/pom.xml b/pom.xml index a58907e..64c785a 100644 --- a/pom.xml +++ b/pom.xml
@@ -27,14 +27,19 @@ <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <Gerrit-PluginName>${project.artifactId}</Gerrit-PluginName> <Gerrit-ApiType>plugin</Gerrit-ApiType> <Gerrit-ReloadMode>reload</Gerrit-ReloadMode> - <Gerrit-PluginName>its-storyboard</Gerrit-PluginName> - <Gerrit-InitStep>com.googlesource.gerrit.plugins.hooks.sb.InitStoryboard</Gerrit-InitStep> - <Gerrit-Module>com.googlesource.gerrit.plugins.hooks.sb.StoryboardModule</Gerrit-Module> - <easymockVersion>3.0</easymockVersion> + <Gerrit-Module>com.googlesource.gerrit.plugins.its.storyboard.StoryboardModule</Gerrit-Module> + <Implementation-Vendor>Hewlett Packard Corp.</Implementation-Vendor> + <Implementation-Url>https://gerrit.googlesource.com/plugins/its-storyboard</Implementation-Url> + <Implementation-Title>${project.artifactId} plugin</Implementation-Title> + <easymockVersion>3.1</easymockVersion> <powermockVersion>1.5</powermockVersion> <slf4jVersion>1.6.2</slf4jVersion> + <powermockVersion>1.5</powermockVersion> + <httpclientVersion>4.3.4</httpclientVersion> + <gsonVersion>2.1</gsonVersion> </properties> <build> @@ -68,15 +73,14 @@ <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> - <Implementation-Vendor>Openstack</Implementation-Vendor> - <Implementation-URL>https://www.openstack.org/</Implementation-URL> - <Implementation-Title>Plugin ${project.artifactId}</Implementation-Title> + <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor> + <Implementation-URL>${Implementation-Url}</Implementation-URL> + <Implementation-Title>${Implementation-Title}</Implementation-Title> <Implementation-Version>${project.version}</Implementation-Version> <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType> <Gerrit-ApiVersion>${project.version}</Gerrit-ApiVersion> <Gerrit-ReloadMode>${Gerrit-ReloadMode}</Gerrit-ReloadMode> <Gerrit-PluginName>${Gerrit-PluginName}</Gerrit-PluginName> - <Gerrit-InitStep>${Gerrit-InitStep}</Gerrit-InitStep> <Gerrit-Module>${Gerrit-Module}</Gerrit-Module> </manifestEntries> </transformer> @@ -100,6 +104,40 @@ <artifactId>its-base</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpclient</artifactId> + <version>${httpclientVersion}</version> + </dependency> + <dependency> + <groupId>com.google.code.gson</groupId> + <artifactId>gson</artifactId> + <version>${gsonVersion}</version> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-log4j12</artifactId> + <version>${slf4jVersion}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.easymock</groupId> + <artifactId>easymock</artifactId> + <version>${easymockVersion}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.powermock</groupId> + <artifactId>powermock-module-junit4</artifactId> + <version>${powermockVersion}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.powermock</groupId> + <artifactId>powermock-api-easymock</artifactId> + <version>${powermockVersion}</version> + <scope>test</scope> + </dependency> </dependencies> <repositories>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardClient.java b/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardClient.java new file mode 100644 index 0000000..b117517 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardClient.java
@@ -0,0 +1,134 @@ +// Copyright (C) 2014 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.storyboard; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.HttpURLConnection; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class StoryboardClient { + + private static final Logger log = LoggerFactory.getLogger( + StoryboardClient.class); + + public static final String STORIES_ENDPOINT = "/api/v1/stories"; + public static final String SYS_INFO_ENDPOINT = "/api/v1/systeminfo"; + + private final String baseUrl; + private final String username; + private final String password; + + public StoryboardClient(final String baseUrl, final String username, + String password) { + this.baseUrl = baseUrl; + this.username = username; + this.password = password; + } + + // generic method to get data from a REST endpoint + public String getData(final String url) throws IOException { + CloseableHttpClient client = HttpClients.createDefault(); + String responseJson = null; + + try { + HttpGet httpget = new HttpGet(url); + log.debug("Making request for " + httpget.getRequestLine()); + CloseableHttpResponse response = client.execute(httpget); + try { + StatusLine sl = response.getStatusLine(); + int responseCode = sl.getStatusCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + log.debug("Retreiving data from response " + httpget.getRequestLine()); + InputStream inputStream = response.getEntity().getContent(); + Reader reader = new InputStreamReader(inputStream); + int contentLength = (int) response.getEntity().getContentLength(); + char[] charArray = new char[contentLength]; + reader.read(charArray); + responseJson = new String(charArray); + log.debug("Data retreived: " + responseJson); + } else { + log.error("Failed request: " + httpget.getRequestLine() + + " with response: " + responseCode); + } + } finally { + response.close(); + } + } finally { + client.close(); + } + return responseJson; + } + + // generic method to POST data with a REST endpoint + public void postData(final String url, final String data) + throws IOException { + CloseableHttpClient httpclient = HttpClients.createDefault(); + + try { + HttpPost httpPost = new HttpPost(url); + httpPost.addHeader("Authorization", "Bearer " + password); + httpPost.addHeader("Content-Type", "application/json; charset=utf-8"); + httpPost.setEntity(new StringEntity(data, "utf-8")); + + log.debug("Executing request " + httpPost.getRequestLine()); + CloseableHttpResponse response = httpclient.execute(httpPost); + try { + int responseCode = response.getStatusLine().getStatusCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + log.info("Updated " + url + " with " + data); + } else { + log.error("Failed to add comment, response: " + responseCode + + " (" + response.getStatusLine().getReasonPhrase() + ")"); + } + } finally { + response.close(); + } + } finally { + httpclient.close(); + } + } + + public String getSysInfo() throws IOException { + return getData(this.baseUrl + SYS_INFO_ENDPOINT); + } + + public String getStory(final String issueId) throws IOException { + return getData(this.baseUrl + STORIES_ENDPOINT + "/" + issueId); + } + + public void addComment(final String issueId, final String comment) + throws IOException { + log.debug("Posting comment with data: ({},{})", issueId, comment); + final String url = baseUrl+STORIES_ENDPOINT+"/"+issueId+"/comments"; + final String escapedComment = comment.replace("\n", "\\n"); + final String json = + "{\"story_id\":\"" + issueId + "\",\"content\":\"" + + escapedComment + "\"}"; + + postData(url, json); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardItsFacade.java b/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardItsFacade.java new file mode 100644 index 0000000..ba74f7c --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardItsFacade.java
@@ -0,0 +1,112 @@ +// Copyright (C) 2014 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.storyboard; + +import java.io.IOException; +import java.net.URL; + +import org.eclipse.jgit.lib.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.inject.Inject; + +import com.googlesource.gerrit.plugins.hooks.its.ItsFacade; + +public class StoryboardItsFacade implements ItsFacade { + private final Logger log = LoggerFactory.getLogger(StoryboardItsFacade.class); + + private static final String GERRIT_CONFIG_USERNAME = "username"; + private static final String GERRIT_CONFIG_PASSWORD = "password"; + private static final String GERRIT_CONFIG_URL = "url"; + + private final StoryboardClient client; + + @Inject + public StoryboardItsFacade(@PluginName String pluginName, + @GerritServerConfig Config cfg) { + final String url = cfg.getString(pluginName, null, GERRIT_CONFIG_URL); + final String username = cfg.getString(pluginName, null, + GERRIT_CONFIG_USERNAME); + final String password = cfg.getString(pluginName, null, + GERRIT_CONFIG_PASSWORD); + + this.client = new StoryboardClient(url, username, password); + } + + @Override + public String healthCheck(final Check check) throws IOException { + // This method is not used, so there is no need to implement it. + return "unknown"; + } + + @Override + public void addComment(final String issueId, final String comment) { + + if (!exists(issueId)) { + log.warn("Story " + issueId + " does not exist, nothing to update"); + return; + } + + try { + client.addComment(issueId, comment); + } catch (IOException e) { + log.error("Error: could not add comment to issue " + issueId); + } + log.info("Updated " + issueId + "with comment: " + comment); + } + + @Override + public void addRelatedLink(final String issueId, final URL relatedUrl, + String description) throws IOException { + addComment(issueId, "Related URL: " + createLinkForWebui( + relatedUrl.toExternalForm(), description)); + } + + @Override + public void performAction(final String issueId, final String actionString) { + // No custom actions at this point. + // + // Note that you can use hashtag names in comments to associate a task + // with a new project. + } + + @Override + public boolean exists(final String issudeId) { + String info = null; + try { + info = client.getStory(issudeId); + } catch (IOException e) { + log.error("Error: Storyboard is not accessible"); + } + if (info != null) { + log.debug("Story exists, info: " + info); + return true; + } + return false; + } + + @Override + public String createLinkForWebui(String url, String text) { + String ret = "[" + url; + if (text != null && !text.isEmpty() && !text.equals(url)) { + ret += "|" + text; + } + ret += "]"; + return ret; + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardModule.java b/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardModule.java new file mode 100644 index 0000000..4c30ce8 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardModule.java
@@ -0,0 +1,57 @@ +// Copyright (C) 2014 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.storyboard; + +import org.eclipse.jgit.lib.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.PluginConfigFactory; +import com.google.inject.AbstractModule; +import com.google.inject.Inject; + +import com.googlesource.gerrit.plugins.hooks.ItsHookModule; +import com.googlesource.gerrit.plugins.hooks.its.ItsFacade; + +public class StoryboardModule extends AbstractModule { + + private static final Logger log = LoggerFactory.getLogger( + StoryboardModule.class); + + private final String pluginName; + private final Config gerritConfig; + private final PluginConfigFactory pluginCfgFactory; + + @Inject + public StoryboardModule(@PluginName final String pluginName, + @GerritServerConfig final Config config, + PluginConfigFactory pluginCfgFactory) { + this.pluginName = pluginName; + this.gerritConfig = config; + this.pluginCfgFactory = pluginCfgFactory; + } + + @Override + protected void configure() { + if (gerritConfig.getString(pluginName, null, "url") != null) { + log.info("Storyboard is configured as ITS"); + bind(ItsFacade.class).toInstance(new StoryboardItsFacade( + pluginName, gerritConfig)); + install(new ItsHookModule(pluginName, pluginCfgFactory)); + } + } +}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md new file mode 100644 index 0000000..5c0d532 --- /dev/null +++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,15 @@ +This @PLUGIN@ plugin is an [`its-base`][its-base] based plugin that integrates +Gerrit and [`Storyboard`][storyboard]. The @PLUGIN@ allows users +to configure how a storyboard story or task should be updated relative +to Gerrit change updates. For example, it can be configured to +automatically add comments to a story when a comment is entered to +an associated Gerrit change. It can also be configured to update a +story's status upon an update to an associated Gerrit change. + +For details on how to install this plugin start with the +[Quick Install Guide][quick]. + +[quick]: quick-install-guide.html +[its-base]: https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-base +[storyboard]: http://git.openstack.org/cgit/openstack-infra/storyboard +
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md index 3079426..d2f8c4d 100644 --- a/src/main/resources/Documentation/build.md +++ b/src/main/resources/Documentation/build.md
@@ -42,3 +42,4 @@ How to build the Gerrit Plugin API is described in the [Gerrit documentation](../../../Documentation/dev-buck.html#_extension_and_plugin_api_jar_files). +
diff --git a/src/main/resources/Documentation/config-connectivity.md b/src/main/resources/Documentation/config-connectivity.md new file mode 100644 index 0000000..a138c82 --- /dev/null +++ b/src/main/resources/Documentation/config-connectivity.md
@@ -0,0 +1,10 @@ +Configuring connectivity for @PLUGIN@ +===================================== + +Please refer to the [quick install guide][quick] for info on how to +configure a connection. + +[quick]: quick-install-guide.html +[Back to @PLUGIN@ documentation index][index] + +[index]: index.html
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md deleted file mode 100644 index bf49e3b..0000000 --- a/src/main/resources/Documentation/config.md +++ /dev/null
@@ -1,6 +0,0 @@ -Plugin @PLUGIN@ -=============== - -This plugin allows to associate Storyboard issues to Git commits thanks to -the Gerrit listener interface. -
diff --git a/src/main/resources/Documentation/quick-install-guide.md b/src/main/resources/Documentation/quick-install-guide.md new file mode 100644 index 0000000..b749a0e --- /dev/null +++ b/src/main/resources/Documentation/quick-install-guide.md
@@ -0,0 +1,125 @@ +Quick Install Guide +=================== + +For general instructions on how to enable and configure an its plugin +please refer to the general [configuration documentation][config-doc] +Instructions in this document are specific to the @PLUGIN@ plugin. + +Install Steps: + +1. Verify Storyboard [REST endpoint][rest-enabled]. +2. [Configure the connection][its-connection]. +3. [Associate a changes with stories][its-associate-change]. +4. [Configure the actions][its-actions] that the plugin will take on a Gerrit change. +5. [Enable the @PLUGIN@ plugin][its-enable] for the Gerrit project. +6. [Install the plugin][its-install] +7. Restart Gerrit + +[rest-enabled]: #rest-enabled +<a name="rest-enabled">Checking REST API availability</a> +--------------------------------------------------------- + +This plugin will connect to Storyboard via it's REST endpoints. +Make sure that the Storyboard REST API is up and running. + +Assuming the Storyboard instance you want to connect to is at +`http://my_storyboard_instance.com/`, open + +``` +http://my_storyboard_instance.com/api/v1/systeminfo +``` + +in your browser. If you get a xml response page without errors, the REST +interface is enabled. + +If you get an error page then you'll need to enable the Storyboard REST API. + +[its-connection]: #its-connection +<a name="its-connection">Connection Configuration</a> +----------------------------------------------------- + +In order for @PLUGIN@ to connect to the REST service of your +Storyboard instance, the url and credentials are required in +your site's `etc/gerrit.config` or `etc/secure.config` under +the `@PLUGIN@` section. + +Example: + +``` +[@PLUGIN@] + url=https://my_storyboard_instance.com + username=USERNAME_TO_CONNECT_TO_STORYBOARD + password=AUTH_TOKEN_FOR_ABOVE_USERNAME +``` + +[its-associate-change]: #its-associate-change +<a name="its-associate-change">Associating Gerrit Changes</a> +------------------------------------------------------------- + +In order for @PLUGIN@ to associate a Gerrit change with +a Storyboard story, a Gerrit commentlink needs to be +defined in `etc/gerrit.config` + +Example: + +``` +[commentLink "@PLUGIN@"] + match = [Ss][Tt][Oo][Rr][Yy][ ]*([1-9][0-9]*) + html = "<a href=\"https://my_storyboard_instance.com/#!/story/$1\">story $1</a>" +``` + +[its-actions]: #its-actions +<a name="its-actions">Configure its actions</a> +----------------------------------------------- + +The @PLUGIN@ plugin can take action when there are updates +to Gerrit changes. Users can define what events will trigger +which actions. To configure this a `etc/its/actions.config` +file is required. + +Example of actions.config: + +``` +# Add a custom comment when a comment has been added to the associated Gerrit change. +[rule "update-comment"] + event-type = comment-added + action = add-velocity-comment inline $commenter-name commented on change ${its.formatLink($change-url, $subject)} +# add a comment only when a user leaves a -2 or a -1 vote on the Code-Review label on the associated Gerrit change. +[rule "comment-on-negative-vote"] + event-type = comment-added + approval-Code-Review = -2,-1 + action = add-comment Boo-hoo, go away! +# add a standard comment when there is a status update to the associated Gerrit change. +[rule "comment-on-status-update"] + event-type = patchset-created,change-abandoned,change-restored,change-merged + action = add-standard-comment +``` + +More detailed information on actions is found the [rules documentation][rules-doc] + +[its-enable]: #its-enable +<a name="its-enable">Enable the Plugin</a> +------------------------------------------------------- + +In order to enable the @PLUGIN@ plugin, an entry must be +added to the project.config file in refs/meta/config. +To enable the plugin for all projects a single entry can +be added to project.config in All-Projects. + +Example: + +``` +[plugin "@PLUGIN@"] + enabled = true +``` + +[its-install]: #its-install +<a name="its-install">Install the Plugin</a> +------------------------------------------------------- + +In order to install the @PLUGIN@ plugin simply copy the built jar +file into the `plugins` folder. + +[config-common-doc]: config-common.html +[config-doc]: config.html +[rules-doc]: config-rulebase-common.html
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardItsFacadeTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardItsFacadeTest.java new file mode 100644 index 0000000..e7619ad --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/its/storyboard/StoryboardItsFacadeTest.java
@@ -0,0 +1,105 @@ +// Copyright (C) 2014 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.its.storyboard; + +import static org.easymock.EasyMock.expect; + +import org.eclipse.jgit.lib.Config; + +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.server.config.FactoryModule; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.googlesource.gerrit.plugins.its.testutil.LoggingMockingTestCase; + +public class StoryboardItsFacadeTest extends LoggingMockingTestCase { + private Injector injector; + private Config serverConfig; + + public void testCreateLinkForWebUiDifferentUrlAndText() { + mockUnconnectableStoryboard(); + + replayMocks(); + + StoryboardItsFacade itsFacade = createStoryboardItsFacade(); + String actual = itsFacade.createLinkForWebui("Test-Url", "Test-Text"); + + assertEquals("[Test-Url|Test-Text]", actual); + } + + public void testCreateLinkForWebUiSameUrlAndText() { + mockUnconnectableStoryboard(); + + replayMocks(); + + StoryboardItsFacade itsFacade = createStoryboardItsFacade(); + String actual = itsFacade.createLinkForWebui("Test-Url", "Test-Url"); + + assertEquals("[Test-Url]", actual); + } + + public void testCreateLinkForWebUiNullText() { + mockUnconnectableStoryboard(); + + replayMocks(); + + StoryboardItsFacade itsFacade = createStoryboardItsFacade(); + String actual = itsFacade.createLinkForWebui("Test-Url", null); + + assertEquals("[Test-Url]", actual); + } + + public void testCreateLinkForWebUiEmptyText() { + mockUnconnectableStoryboard(); + + replayMocks(); + + StoryboardItsFacade itsFacade = createStoryboardItsFacade(); + String actual = itsFacade.createLinkForWebui("Test-Url", ""); + + assertEquals("[Test-Url]", actual); + } + + private StoryboardItsFacade createStoryboardItsFacade() { + return injector.getInstance(StoryboardItsFacade.class); + } + + private void mockUnconnectableStoryboard() { + expect(serverConfig.getString("its-storyboard", null, "url")) + .andReturn("<no-url>").anyTimes(); + expect(serverConfig.getString("its-storyboard", null, "username")) + .andReturn("none").anyTimes(); + expect(serverConfig.getString("its-storyboard", null, "password")) + .andReturn("none").anyTimes(); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + + injector = Guice.createInjector(new TestModule()); + } + + private class TestModule extends FactoryModule { + @Override + protected void configure() { + serverConfig = createMock(Config.class); + bind(Config.class).annotatedWith(GerritServerConfig.class) + .toInstance(serverConfig); + bind(String.class).annotatedWith(PluginName.class) + .toInstance("its-storyboard"); + } + } +} \ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/testutil/LoggingMockingTestCase.java b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/LoggingMockingTestCase.java new file mode 100644 index 0000000..a2d4d00 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/LoggingMockingTestCase.java
@@ -0,0 +1,107 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.testutil; + +import com.google.common.collect.Lists; +import com.googlesource.gerrit.plugins.its.testutil.MockingTestCase; +import com.googlesource.gerrit.plugins.its.testutil.log.LogUtil; + +import org.apache.log4j.spi.LoggingEvent; +import org.junit.After; + +import java.util.Iterator; +import org.apache.log4j.Level; + +public abstract class LoggingMockingTestCase extends MockingTestCase { + + private java.util.Collection<LoggingEvent> loggedEvents; + + protected final void assertLogMessageContains(String needle, Level level) { + LoggingEvent hit = null; + Iterator<LoggingEvent> iter = loggedEvents.iterator(); + while (hit == null && iter.hasNext()) { + LoggingEvent event = iter.next(); + if (event.getRenderedMessage().contains(needle)) { + if (level == null || level.equals(event.getLevel())) { + hit = event; + } + } + } + assertNotNull("Could not find log message containing '" + needle + "'", + hit); + assertTrue("Could not remove log message containing '" + needle + "'", + loggedEvents.remove(hit)); + } + + protected final void assertLogMessageContains(String needle) { + assertLogMessageContains(needle, null); + } + + protected final void assertLogThrowableMessageContains(String needle) { + LoggingEvent hit = null; + Iterator<LoggingEvent> iter = loggedEvents.iterator(); + while (hit == null && iter.hasNext()) { + LoggingEvent event = iter.next(); + + if (event.getThrowableInformation().getThrowable().toString() + .contains(needle)) { + hit = event; + } + } + assertNotNull("Could not find log message with a Throwable containing '" + + needle + "'", hit); + assertTrue("Could not remove log message with a Throwable containing '" + + needle + "'", loggedEvents.remove(hit)); + } + + // As the PowerMock runner does not pass through runTest, we inject log + // verification through @After + @After + public final void assertNoUnassertedLogEvents() { + if (loggedEvents.size() > 0) { + LoggingEvent event = loggedEvents.iterator().next(); + String msg = "Found untreated logged events. First one is:\n"; + msg += event.getRenderedMessage(); + if (event.getThrowableInformation() != null) { + msg += "\n" + event.getThrowableInformation().getThrowable(); + } + fail(msg); + } + } + + @Override + public void setUp() throws Exception { + super.setUp(); + loggedEvents = Lists.newArrayList(); + + // The logger we're interested is class name without the trailing "Test". + // While this is not the most general approach it is sufficient for now, + // and we can improve later to allow tests to specify which loggers are + // to check. + String logName = this.getClass().getCanonicalName(); + logName = logName.substring(0, logName.length()-4); + LogUtil.logToCollection(logName, loggedEvents); + } + + @Override + protected void runTest() throws Throwable { + super.runTest(); + // Plain JUnit runner does not pick up @After, so we add it here + // explicitly. Note, that we cannot put this into tearDown, as failure + // to verify mocks would bail out and might leave open resources from + // subclasses open. + assertNoUnassertedLogEvents(); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/testutil/MockingTestCase.java b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/MockingTestCase.java new file mode 100644 index 0000000..060269b --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/MockingTestCase.java
@@ -0,0 +1,153 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.testutil; + +import junit.framework.TestCase; + +import org.easymock.EasyMock; +import org.easymock.IMocksControl; +import org.junit.After; +import org.junit.runner.RunWith; +import org.powermock.api.easymock.PowerMock; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.util.ArrayList; +import java.util.Collection; + +/** + * Test case with some support for automatically verifying mocks. + */ +public abstract class MockingTestCase extends TestCase { + private Collection<Object> mocks; + private Collection<IMocksControl> mockControls; + private boolean mocksReplayed; + private boolean usePowerMock; + + /** + * Create and register a mock control. + * + * @return The mock control instance. + */ + protected final IMocksControl createMockControl() { + IMocksControl mockControl = EasyMock.createControl(); + assertTrue("Adding mock control failed", mockControls.add(mockControl)); + return mockControl; + } + + /** + * Create and register a mock. + * + * Creates a mock and registers it in the list of created mocks, so it gets + * treated automatically upon {@code replay} and {@code verify}; + * @param toMock The class to create a mock for. + * @return The mock instance. + */ + protected final <T> T createMock(Class<T> toMock) { + return createMock(toMock, null); + } + + /** + * Create a mock for a mock control and register a mock. + * + * Creates a mock and registers it in the list of created mocks, so it gets + * treated automatically upon {@code replay} and {@code verify}; + * @param toMock The class to create a mock for. + * @param control The mock control to create the mock on. If null, do not use + * a specific control. + * @return The mock instance. + */ + protected final <T> T createMock(Class<T> toMock, IMocksControl control) { + assertFalse("Mocks have already been set to replay", mocksReplayed); + final T mock; + if (control == null) { + if (usePowerMock) { + mock = PowerMock.createMock(toMock); + } else { + mock = EasyMock.createMock(toMock); + } + assertTrue("Adding " + toMock.getName() + " mock failed", + mocks.add(mock)); + } else { + mock = control.createMock(toMock); + } + return mock; + } + + /** + * Set all registered mocks to replay + */ + protected final void replayMocks() { + assertFalse("Mocks have already been set to replay", mocksReplayed); + if (usePowerMock) { + PowerMock.replayAll(); + } else { + EasyMock.replay(mocks.toArray()); + } + for (IMocksControl mockControl : mockControls) { + mockControl.replay(); + } + mocksReplayed = true; + } + + /** + * Verify all registered mocks + * + * This method is called automatically at the end of a test. Nevertheless, + * it is safe to also call it beforehand, if this better meets the + * verification part of a test. + */ + // As the PowerMock runner does not pass through runTest, we inject mock + // verification through @After + @After + public final void verifyMocks() { + if (!mocks.isEmpty() || !mockControls.isEmpty()) { + assertTrue("Created mocks have not been set to replay. Call replayMocks " + + "within the test", mocksReplayed); + if (usePowerMock) { + PowerMock.verifyAll(); + } else { + EasyMock.verify(mocks.toArray()); + } + for (IMocksControl mockControl : mockControls) { + mockControl.verify(); + } + } + } + + @Override + public void setUp() throws Exception { + super.setUp(); + + usePowerMock = false; + RunWith runWith = this.getClass().getAnnotation(RunWith.class); + if (runWith != null) { + usePowerMock = PowerMockRunner.class.isAssignableFrom(runWith.value()); + } + + mocks = new ArrayList<Object>(); + mockControls = new ArrayList<IMocksControl>(); + mocksReplayed = false; + } + + @Override + protected void runTest() throws Throwable { + super.runTest(); + // Plain JUnit runner does not pick up @After, so we add it here + // explicitly. Note, that we cannot put this into tearDown, as failure + // to verify mocks would bail out and might leave open resources from + // subclasses open. + verifyMocks(); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/testutil/log/CollectionAppender.java b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/log/CollectionAppender.java new file mode 100644 index 0000000..8f21bde --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/log/CollectionAppender.java
@@ -0,0 +1,58 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.testutil.log; + +import com.google.common.collect.Lists; + +import org.apache.log4j.AppenderSkeleton; +import org.apache.log4j.spi.LoggingEvent; + +import java.util.Collection; +import java.util.LinkedList; + +/** + * Log4j appender that logs into a list + */ +public class CollectionAppender extends AppenderSkeleton { + private Collection<LoggingEvent> events; + + public CollectionAppender() { + events = new LinkedList<LoggingEvent>(); + } + + public CollectionAppender(Collection<LoggingEvent> events) { + this.events = events; + } + + @Override + public boolean requiresLayout() { + return false; + } + + @Override + protected void append(LoggingEvent event) { + if (! events.add(event)) { + throw new RuntimeException("Could not append event " + event); + } + } + + @Override + public void close() { + } + + public Collection<LoggingEvent> getLoggedEvents() { + return Lists.newLinkedList(events); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/testutil/log/LogUtil.java b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/log/LogUtil.java new file mode 100644 index 0000000..4fee52f --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/its/testutil/log/LogUtil.java
@@ -0,0 +1,35 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.its.testutil.log; + +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; +import org.apache.log4j.spi.LoggingEvent; + +import com.googlesource.gerrit.plugins.its.testutil.log.CollectionAppender; + + +import java.util.Collection; + +public class LogUtil { + public static CollectionAppender logToCollection(String logName, + Collection<LoggingEvent> collection) { + Logger log = LogManager.getLogger(logName); + CollectionAppender listAppender = new CollectionAppender(collection); + log.removeAllAppenders(); + log.addAppender(listAppender); + return listAppender; + } +}