diff --git a/src/main/java/com/privalia/qa/aspects/JiraTagAspect.java b/src/main/java/com/privalia/qa/aspects/JiraTagAspect.java new file mode 100644 index 00000000..a49b57f4 --- /dev/null +++ b/src/main/java/com/privalia/qa/aspects/JiraTagAspect.java @@ -0,0 +1,62 @@ +package com.privalia.qa.aspects; + +import com.privalia.qa.utils.JiraConnector; +import io.cucumber.testng.FeatureWrapper; +import io.cucumber.testng.PickleWrapper; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Aspect for managing the @jira() tag on a feature/scenario + */ +@Aspect +public class JiraTagAspect { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getCanonicalName()); + + JiraConnector jc = new JiraConnector(); + + /** + * Pointcut is executed for {@link io.cucumber.testng.AbstractTestNGCucumberTests#runScenario(PickleWrapper, FeatureWrapper)} + * @param pickleWrapper the pickleWrapper + * @param featureWrapper the featureWrapper + */ + @Pointcut("execution (void *.runScenario(..)) && args(pickleWrapper, featureWrapper)") + protected void jiraTagPointcutScenario(PickleWrapper pickleWrapper, FeatureWrapper featureWrapper) { + } + + @Around(value = "jiraTagPointcutScenario(pickleWrapper, featureWrapper)") + public void aroundJiraTagPointcut(ProceedingJoinPoint pjp, PickleWrapper pickleWrapper, FeatureWrapper featureWrapper) throws Throwable { + + List tags = pickleWrapper.getPickle().getTags(); + String scenarioName = pickleWrapper.getPickle().getName(); + + String ticket = this.jc.getFirstTicketReference(tags); + + if (ticket != null) { + try { + if (!jc.entityShouldRun(ticket)) { + logger.warn("Scenario '" + scenarioName + "' was ignored, it is in a non runnable status in Jira."); + return; + } else { + pjp.proceed(); + } + } catch (Exception e) { + logger.warn("Could not retrieve info of ticket " + ticket + " from jira: " + e.getMessage() + ". Proceeding with execution..."); + pjp.proceed(); + } + + } else { + pjp.proceed(); + } + } + +} diff --git a/src/main/java/com/privalia/qa/specs/HookGSpec.java b/src/main/java/com/privalia/qa/specs/HookGSpec.java index 7a35acec..b8b38a96 100644 --- a/src/main/java/com/privalia/qa/specs/HookGSpec.java +++ b/src/main/java/com/privalia/qa/specs/HookGSpec.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ning.http.client.AsyncHttpClient; import com.ning.http.client.AsyncHttpClientConfig; +import com.privalia.qa.utils.JiraConnector; import com.privalia.qa.utils.ThreadProperty; import io.appium.java_client.MobileDriver; import io.appium.java_client.android.AndroidDriver; @@ -51,6 +52,9 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -77,6 +81,7 @@ public class HookGSpec extends BaseGSpec { private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(HookGSpec.class); + JiraConnector jiraConnector = new JiraConnector(); protected WebDriver driver; @@ -472,10 +477,29 @@ public void restClientTeardown() { } /** - * Disconnect any remaining open SSH connection after each scenario is completed + * Will check if the scenario contains any reference to a Jira ticket and will try to update + * its status based on the result of the scenario execution. It will also try to close any remaining + * SSH connection + * @param scenario Scenario */ @After(order = 10) - public void remoteSSHConnectionTeardown() { + public void teardown(Scenario scenario) { + + if (scenario.isFailed()) { + Collection tags = scenario.getSourceTagNames(); + String ticket = this.jiraConnector.getFirstTicketReference(new ArrayList(tags)); + + if (ticket != null) { + try { + commonspec.getLogger().debug("Updating ticket " + ticket + " in Jira..."); + this.jiraConnector.transitionEntity(ticket); + this.jiraConnector.postCommentToEntity(ticket, "Scenario '" + scenario.getName() + "' failed at: " + scenario.getId()); + } catch (Exception e) { + commonspec.getLogger().warn("Could not change the status of entity " + ticket + " in jira: " + e.getMessage()); + } + } + } + if (commonspec.getRemoteSSHConnection() != null) { commonspec.getLogger().debug("Closing SSH remote connection"); commonspec.getRemoteSSHConnection().getSession().disconnect(); @@ -494,4 +518,5 @@ public void sqlConnectionClose() throws Exception { commonspec.getSqlClient().disconnect(); } } + } diff --git a/src/main/java/com/privalia/qa/utils/JiraConnector.java b/src/main/java/com/privalia/qa/utils/JiraConnector.java new file mode 100644 index 00000000..7534a70b --- /dev/null +++ b/src/main/java/com/privalia/qa/utils/JiraConnector.java @@ -0,0 +1,228 @@ +package com.privalia.qa.utils; + +import com.jayway.jsonpath.JsonPath; +import com.ning.http.client.AsyncHttpClient; +import com.ning.http.client.Request; +import com.ning.http.client.RequestBuilder; +import com.ning.http.client.Response; +import com.privalia.qa.lookups.DefaultLookUp; +import org.apache.commons.text.StringSubstitutor; +import net.minidev.json.JSONArray; +import org.apache.commons.text.lookup.StringLookupFactory; + +import java.util.List; +import java.util.concurrent.Future; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * An small utility for interacting with entities in Jira + * @author José Fernández + */ +public class JiraConnector { + + public static final String JIRA_PROPERTIES_FILE = "jira.properties"; + + final StringSubstitutor interpolator = StringSubstitutor.createInterpolator(); + + AsyncHttpClient client = new AsyncHttpClient(); + + /** + * Reads the given key from the properties file. The system will try to locate the key first in + * the maven variables (System.getProperty) and if not found will look for it in the properties file. + * If the value is still not found it will return the default value (if provided) or an exception + * @param property key + * @return value + */ + private String getProperty(String property, String defaultValue) { + + if (System.getProperty(property) != null) { + return System.getProperty(property); + } + + interpolator.setEnableUndefinedVariableException(true); + + if (defaultValue != null) { + property = property + ":-" + defaultValue; + } + + return interpolator.replace("${properties:src/test/resources/" + JIRA_PROPERTIES_FILE + "::" + property + "}"); + } + + /** + * Retrieves the current entity status + * @param entity Entity identifier (i.e QMS-123) + * @return Status as string (i.e 'In Progress') + * @throws Exception Exception + */ + private String getEntityStatus(String entity) throws Exception { + + String jiraURL = this.getProperty("jira.server.url", null); + String jiraToken = this.getProperty("jira.personal.access.token", null); + + Request getRequest = new RequestBuilder() + .setMethod("GET") + .setUrl(jiraURL + "/rest/api/2/issue/" + entity) + .addHeader("Authorization", "Bearer " + jiraToken) + .build(); + + Future f = this.client.executeRequest(getRequest); + Response r = (Response) f.get(); + + if (r.getStatusCode() != 200) { + throw new Exception("Unexpected status code response:" + r.getStatusCode() + ". Body: '" + r.getResponseBody() + "'"); + } + + return JsonPath.read(r.getResponseBody(), "$.fields.status.name").toString().toUpperCase(); + } + + /** + * Determines if the entity status matches any of the expected statuses + * @param entity Entity identifier (i.e QMS-123) + * @return True if the entity status is within the expected statuses + * @throws Exception Exception + */ + public Boolean entityShouldRun(String entity) throws Exception { + + String[] valid_statuses = this.getProperty("jira.valid.runnable.statuses", "Done,Deployed").split(","); + String entity_current_status = this.getEntityStatus(entity).toUpperCase(); + + for (String status: valid_statuses) { + if (entity_current_status.matches(status.toUpperCase())) { + return true; + } + } + + return false; + } + + /** + * Change the status of an entity to the Given Status by name. The status name should match exactly a valid + * status for that entity + * @param entity Entity identifier (i.e QMS-123) + * @param new_status New status (i.e 'Done') + * @throws Exception Exception + */ + private void transitionEntityToGivenStatus(String entity, String new_status) throws Exception { + + int targetTransition = this.getTransitionIDForEntityByName(entity, new_status); + + String jiraURL = this.getProperty("jira.server.url", ""); + String jiraToken = this.getProperty("jira.personal.access.token", ""); + + Request postRequest = new RequestBuilder() + .setMethod("POST") + .setUrl(jiraURL + "/rest/api/2/issue/" + entity + "/transitions") + .addHeader("Authorization", "Bearer " + jiraToken) + .addHeader("Content-Type", "application/json") + .setBody("{\"transition\": {\"id\": " + targetTransition + " }}") + .build(); + + Future f = this.client.executeRequest(postRequest); + Response r = (Response) f.get(); + + if (r.getStatusCode() != 204) { + throw new Exception("Unexpected status code response:" + r.getStatusCode() + ". Body: '" + r.getResponseBody() + "'"); + } + + } + + /** + * Gets the id of the transition by the given name + * @param entity Entity identifier (i.e QMS-123) + * @param transitionName Transition name (i.e 'In Progress') + * @return Id of the transition for that name + * @throws Exception Exception + */ + private int getTransitionIDForEntityByName(String entity, String transitionName) throws Exception { + + String jiraURL = this.getProperty("jira.server.url", ""); + String jiraToken = this.getProperty("jira.personal.access.token", ""); + + Request getRequest = new RequestBuilder() + .setMethod("GET") + .setUrl(jiraURL + "/rest/api/2/issue/" + entity + "/transitions") + .addHeader("Authorization", "Bearer " + jiraToken) + .build(); + + Future f = this.client.executeRequest(getRequest); + Response r = (Response) f.get(); + + if (r.getStatusCode() != 200) { + throw new Exception("Unexpected status code response:" + r.getStatusCode() + ". Body: '" + r.getResponseBody() + "'"); + } + + Object transitionStrings = JsonPath.read(r.getResponseBody(), "$.transitions[?(@.name=='" + transitionName + "')].id"); + JSONArray ja = (JSONArray) transitionStrings; + + if (ja.isEmpty()) { + throw new IndexOutOfBoundsException("Could not find the transition '" + transitionName + "' in the list of valid transitions for entity '" + entity + "'"); + } else { + return Integer.valueOf(ja.get(0).toString()); + } + + } + + /** + * Adds a new comment to the entity + * @param entity Entity identifier (i.e QMS-123) + * @param message Message to post + * @throws Exception Exception + */ + public void postCommentToEntity(String entity, String message) throws Exception { + + String jiraURL = this.getProperty("jira.server.url", ""); + String jiraToken = this.getProperty("jira.personal.access.token", ""); + + Request postRequest = new RequestBuilder() + .setMethod("POST") + .setUrl(jiraURL + "/rest/api/2/issue/" + entity + "/comment") + .addHeader("Authorization", "Bearer " + jiraToken) + .addHeader("Content-Type", "application/json") + .setBody("{\"body\": \"" + message + "\"}") + .build(); + + Future f = this.client.executeRequest(postRequest); + Response r = (Response) f.get(); + + if (r.getStatusCode() != 201) { + throw new Exception("Unexpected status code response:" + r.getStatusCode() + ". Body: '" + r.getResponseBody() + "'"); + } + } + + /** + * Transition (change status) of the entity to the value provided in the properties file. Will + * default to "In Progress" is the value is not found + * @param entity Entity identifier (i.e QMS-123) + * @throws Exception Exception + */ + public void transitionEntity(String entity) throws Exception { + + String jiraTransitionToStatus = this.getProperty("jira.transition.if.fail.status", "TO REVIEW"); + Boolean jiraTransition = Boolean.valueOf(this.getProperty("jira.transition.if.fail", "true")); + + if (jiraTransition) { + this.transitionEntityToGivenStatus(entity, jiraTransitionToStatus); + } + + } + + /** + * Returns the first reference to the jira ticket from the tag reference + * @param tags List of thats (i.e @ignore, @jira(QMS-123)) + * @return The first ticket reference (i.e QMS-123) + */ + public String getFirstTicketReference(List tags) { + String pattern = "@jira\\((.*)\\)"; + Pattern r = Pattern.compile(pattern); + + for (String tag: tags) { + Matcher m = r.matcher(tag); + if (m.find()) { + return m.group(1); + } + } + + return null; + } +} diff --git a/src/test/java/com/privalia/qa/ATests/JiraTagIT.java b/src/test/java/com/privalia/qa/ATests/JiraTagIT.java new file mode 100644 index 00000000..1bf216ab --- /dev/null +++ b/src/test/java/com/privalia/qa/ATests/JiraTagIT.java @@ -0,0 +1,12 @@ +package com.privalia.qa.ATests; + +import com.privalia.qa.utils.BaseGTest; +import io.cucumber.testng.CucumberOptions; + +@CucumberOptions( + features = { + "src/test/resources/features/jiraTag.feature", + }, + glue = "com.privalia.qa.specs") +public class JiraTagIT extends BaseGTest { +} diff --git a/src/test/java/com/privalia/qa/ATests/JiraTagTest.java b/src/test/java/com/privalia/qa/ATests/JiraTagTest.java new file mode 100644 index 00000000..f4879f61 --- /dev/null +++ b/src/test/java/com/privalia/qa/ATests/JiraTagTest.java @@ -0,0 +1,57 @@ +package com.privalia.qa.ATests; + +import com.privalia.qa.utils.JiraConnector; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +public class JiraTagTest { + + JiraConnector jc = new JiraConnector(); + + @BeforeTest(enabled = false) + public void setUp() throws Exception { + System.setProperty("jira.transition.if.fail.status", "Done"); + this.jc.transitionEntity("QMS-990"); + System.clearProperty("jira.transition.if.fail.status"); + } + + @Test(enabled = false) + public void shouldReturnTrueIfEntitiyStatusMatchesRunnableStatuses() throws Exception { + Boolean shouldRun = this.jc.entityShouldRun("QMS-990"); + assertThat(shouldRun).isTrue(); + } + + @Test(enabled = false) + public void shouldReturnFalseIfEntitiyStatusDifferentFronRunnableStatuses() throws Exception { + System.setProperty("jira.valid.runnable.statuses", "READY,QA READY,DEPLOYED"); + Boolean shouldRun = this.jc.entityShouldRun("QMS-990"); + assertThat(shouldRun).isFalse(); + System.clearProperty("jira.valid.runnable.statuses"); + } + + @Test(enabled = false) + public void shouldAddANewCommentToEntity() throws Exception { + this.jc.postCommentToEntity("QMS-990", "This is a test message"); + } + + @Test(enabled = false) + public void shouldReturnExceptionIkFeyVariablesNotFound() { + assertThatThrownBy(() -> { + System.setProperty("jira.server.url", null); + this.jc.transitionEntity("QMS-990"); + }).isInstanceOf(NullPointerException.class); + } + + @Test + public void shouldReturnTheTicketFromTheTag() throws Exception { + List tags = Arrays.asList("@jira(QMS-990)", "@ignore", "@jira(QMS-123)"); + assertThat("QMS-990").isEqualToIgnoringCase(this.jc.getFirstTicketReference(tags)); + } +} diff --git a/src/test/resources/META-INF/aop.xml b/src/test/resources/META-INF/aop.xml index 8a31b7bc..5f55be6f 100644 --- a/src/test/resources/META-INF/aop.xml +++ b/src/test/resources/META-INF/aop.xml @@ -22,6 +22,7 @@ + diff --git a/src/test/resources/features/jiraTag.feature b/src/test/resources/features/jiraTag.feature new file mode 100644 index 00000000..30c74bfc --- /dev/null +++ b/src/test/resources/features/jiraTag.feature @@ -0,0 +1,15 @@ +@ignore +Feature: Testing the Jira Integration + + Using the @jira() you can control the execution of scenarios based on its status + in Jira as well as update an entity status in Jira based on the result of + the scenario execution. You can fully configure the behavior of this tag using the + configuration file located at src/test/resources/jira.properties + + @jira(QMS-990) + Scenario: A new element is inserted via a POST call + Given I send requests to '${REST_SERVER_HOST}:3000' + When I send a 'POST' request to '/posts' based on 'schemas/mytestdata.json' as 'json' + Then the service response status must be '201' + And I save element '$.title' in environment variable 'TITLE' + Then '${TITLE}' matches 'This is a test' \ No newline at end of file diff --git a/src/test/resources/jira.properties b/src/test/resources/jira.properties new file mode 100644 index 00000000..fb1ce15f --- /dev/null +++ b/src/test/resources/jira.properties @@ -0,0 +1,25 @@ +# The Jira connector allows you to annotate your scenarios with a reference to an entity +# in Jira, for example @jira(QMS-123) +# +# This will allow you to control the execution of scenarios based on the status +# of the linked entity in Jira as well as to update the status of the entity +# in Jira if the Scenario fails. You must create a file like this one under +# src/test/resources/jira.properties +# +# All variables can also be set with maven variables (i.e -Djira.personal.access.token=abcdefg123456) +# in case you would like to obfuscate them for privacy reasons + +# Base URL of the Jira server +jira.server.url=https://my.jira.server + +# Personal authentication token +jira.personal.access.token=abcdefg123456 + +# Will run the scenario only if the linked Jira entity is in one of the given statuses (if not specified, defaults to 'Done,Deployed') +jira.valid.runnable.statuses=Done,Deployed + +# Change linked Jira entity status if scenario fails (if not specified, defaults to 'true') +jira.transition.if.fail=true + +# If jira.transition.if.fail=true, the linked Jira entity will transition to this status (if not specified, defaults to 'TO REVIEW') +jira.transition.if.fail.status=TO REVIEW \ No newline at end of file