Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions system-x/services/jira/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# TNB :: System-X :: Services :: Jira

Jira integration service for the TNB test framework. Provides CRUD operations on Jira issues via the [Jira REST API v3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/).

## API Reference

The OpenAPI specification for the Jira REST API is available at:
https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json

## Available Operations

| Method | Description |
|--------|-------------|
| `createIssue(projectKey, issueSummary)` | Create a Bug issue, returns issue key |
| `deleteIssue(issueKey)` | Delete an issue and its subtasks |
| `getIssue(issueKey)` | Get issue details |
| `getIssues(jql)` | Search issues using JQL |
| `getIssues(project, customJQL)` | Search issues within a project |
| `getComments(issueKey)` | Get all comments on an issue |
| `addComment(issueKey, content)` | Add a comment to an issue |
| `setTransition(issueKey, transitionId)` | Transition an issue to a new status |
53 changes: 0 additions & 53 deletions system-x/services/jira/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,59 +11,6 @@
</parent>

<artifactId>system-x-jira</artifactId>
<version>1.0-SNAPSHOT</version>
<name>TNB :: System-X :: Services :: Jira</name>

<properties>
<jira.client.version>6.0.2</jira.client.version>
<fugue.version>6.1.2</fugue.version>
<!-- RESTEasy 3.x uses javax.ws.rs, compatible with Jira client needs -->
<resteasy.version>3.15.6.Final</resteasy.version>
</properties>

<dependencies>
<dependency>
<groupId>com.atlassian.jira</groupId>
<artifactId>jira-rest-java-client-core</artifactId>
<version>${jira.client.version}</version>
<exclusions>
<exclusion>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.atlassian.fugue</groupId>
<artifactId>fugue</artifactId>
<version>${fugue.version}</version>
</dependency>
<!-- can be removed when jira client version >= 7.x -->
<dependency>
<groupId>javax.ws.rs</groupId>
<artifactId>javax.ws.rs-api</artifactId>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxb-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
</dependencies>

<repositories>
<repository>
<id>atlassian</id>
<url>https://maven.atlassian.com/content/repositories/atlassian-public/</url>
</repository>
</repositories>
</project>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package software.tnb.jira.service;

import software.tnb.common.client.NoClient;
import software.tnb.common.service.Service;
import software.tnb.jira.account.JiraAccount;
import software.tnb.jira.validation.JiraValidation;
Expand All @@ -9,41 +10,19 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.atlassian.jira.rest.client.api.JiraRestClient;
import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory;
import com.google.auto.service.AutoService;

import java.net.URI;
import java.net.URISyntaxException;

@AutoService(Jira.class)
public class Jira extends Service<JiraAccount, JiraRestClient, JiraValidation> {
public class Jira extends Service<JiraAccount, NoClient, JiraValidation> {
private static final Logger LOG = LoggerFactory.getLogger(Jira.class);

@Override
protected JiraRestClient client() {
if (client == null) {
LOG.debug("Creating new Jira client");
try {
client = new AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication(new URI(account().getJiraUrl()),
account().getUsername(), account().getPassword());
} catch (URISyntaxException e) {
throw new RuntimeException("Unable to create jira client", e);
}

}
return client;
}

@Override
public void afterAll(ExtensionContext context) throws Exception {
client.close();
client = null;
}

@Override
public void beforeAll(ExtensionContext context) throws Exception {
LOG.debug("Creating new Jira validation");
validation = new JiraValidation(client());
validation = new JiraValidation(account());
}
}
Original file line number Diff line number Diff line change
@@ -1,81 +1,154 @@
package software.tnb.jira.validation;

import software.tnb.common.utils.HTTPUtils;
import software.tnb.common.validation.Validation;
import software.tnb.jira.account.JiraAccount;
import software.tnb.jira.validation.model.Issue;

import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.atlassian.jira.rest.client.api.JiraRestClient;
import com.atlassian.jira.rest.client.api.domain.Attachment;
import com.atlassian.jira.rest.client.api.domain.Comment;
import com.atlassian.jira.rest.client.api.domain.IssueType;
import com.atlassian.jira.rest.client.api.domain.input.IssueInputBuilder;
import com.atlassian.jira.rest.client.api.domain.input.TransitionInput;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.StreamSupport;
import java.util.Map;

import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.RequestBody;

public class JiraValidation implements Validation {
private static final Logger LOG = LoggerFactory.getLogger(JiraValidation.class);
private static final MediaType JSON = MediaType.get("application/json");
private static final String API_PATH = "/rest/api/3";

private final JiraAccount account;
private final HTTPUtils httpUtils;
private final Map<String, String> headers;

public JiraValidation(JiraAccount account) {
this.account = account;
this.httpUtils = HTTPUtils.getInstance();
this.headers = Map.of(
"Authorization", Credentials.basic(account.getUsername(), account.getPassword()),
"Content-Type", "application/json",
"Accept", "application/json"
);
}

private final JiraRestClient client;

public JiraValidation(JiraRestClient client) {
this.client = client;
private String apiUrl(String path) {
return account.getJiraUrl() + API_PATH + path;
}

/**
* Create issue in given project.
*
* @param projectKey key of project where issue will be created
* @param issueSummary name of issue to be created
* @return id of created issue
* @return key of created issue
*/
public String createIssue(String projectKey, String issueSummary) {
final Optional<IssueType> bugTypeId = StreamSupport.stream(client.getMetadataClient().getIssueTypes().claim().spliterator(), false)
.filter(t -> "bug".equalsIgnoreCase(t.getName())).findAny();

if (bugTypeId.isEmpty()) {
throw new RuntimeException("Unable to find bug type id");
JSONObject body = new JSONObject()
.put("fields", new JSONObject()
.put("project", new JSONObject().put("key", projectKey))
.put("summary", issueSummary)
.put("issuetype", new JSONObject().put("name", "Bug")));

HTTPUtils.Response response = httpUtils.post(
apiUrl("/issue"),
RequestBody.create(body.toString(), JSON),
headers
);

if (!response.isSuccessful()) {
throw new RuntimeException("Unable to create issue: " + response.getBody());
}

final IssueInputBuilder builder = new IssueInputBuilder()
.setProjectKey(projectKey)
.setIssueTypeId(bugTypeId.get().getId())
.setSummary(issueSummary);
final String key = client.getIssueClient().createIssue(builder.build()).claim().getKey();
String key = new JSONObject(response.getBody()).getString("key");
LOG.debug("Created a new issue with key {}", key);
return key;
}

public void deleteIssue(String issueKey) {
LOG.debug("Deleting issue {}", issueKey);
client.getIssueClient().deleteIssue(issueKey, true).claim();
httpUtils.delete(apiUrl("/issue/" + issueKey + "?deleteSubtasks=true"), headers);
}

public List<String> getComments(String issueKey) {
LOG.debug("Getting comments of {}", issueKey);
return StreamSupport.stream(client.getIssueClient().getIssue(issueKey).claim().getComments().spliterator(), false)
.map(Comment::getBody).toList();
HTTPUtils.Response response = httpUtils.get(apiUrl("/issue/" + issueKey + "/comment"), headers);

if (!response.isSuccessful()) {
throw new RuntimeException("Unable to get comments for issue " + issueKey + ": " + response.getBody());
}

JSONArray comments = new JSONObject(response.getBody()).getJSONArray("comments");
List<String> result = new ArrayList<>();
for (int i = 0; i < comments.length(); i++) {
JSONObject commentBody = comments.getJSONObject(i).optJSONObject("body");
if (commentBody != null) {
result.add(extractTextFromAdf(commentBody));
} else {
result.add(comments.getJSONObject(i).optString("body", ""));
}
}
return result;
}

public Issue getIssue(String issueKey) {
LOG.debug("Getting issue {}", issueKey);
return convertToIssue(client.getIssueClient().getIssue(issueKey).claim());
HTTPUtils.Response response = httpUtils.get(apiUrl("/issue/" + issueKey), headers);

if (!response.isSuccessful()) {
throw new RuntimeException("Unable to get issue " + issueKey + ": " + response.getBody());
}

return convertToIssue(new JSONObject(response.getBody()));
}

public void addComment(String issueKey, String content) {
LOG.debug("Adding comment {} to issue {}", content, issueKey);
final URI commentsUri = client.getIssueClient().getIssue(issueKey).claim().getCommentsUri();
client.getIssueClient().addComment(commentsUri, Comment.valueOf(content)).claim();
JSONObject body = new JSONObject()
.put("body", new JSONObject()
.put("type", "doc")
.put("version", 1)
.put("content", new JSONArray()
.put(new JSONObject()
.put("type", "paragraph")
.put("content", new JSONArray()
.put(new JSONObject()
.put("type", "text")
.put("text", content))))));

HTTPUtils.Response response = httpUtils.post(
apiUrl("/issue/" + issueKey + "/comment"),
RequestBody.create(body.toString(), JSON),
headers
);

if (!response.isSuccessful()) {
throw new RuntimeException("Unable to add comment to issue " + issueKey + ": " + response.getBody());
}
}

public List<Issue> getIssues(String jql) {
return StreamSupport.stream(client.getSearchClient().searchJql(jql).claim().getIssues().spliterator(), false)
.map(this::convertToIssue).toList();
HTTPUtils.Response response = httpUtils.post(
apiUrl("/search"),
RequestBody.create(new JSONObject().put("jql", jql).toString(), JSON),
headers
);

if (!response.isSuccessful()) {
throw new RuntimeException("Unable to search issues: " + response.getBody());
}

JSONArray issues = new JSONObject(response.getBody()).getJSONArray("issues");
List<Issue> result = new ArrayList<>();
for (int i = 0; i < issues.length(); i++) {
result.add(convertToIssue(issues.getJSONObject(i)));
}
return result;
}

public List<Issue> getIssues(String project, String customJQL) {
Expand All @@ -84,25 +157,65 @@ public List<Issue> getIssues(String project, String customJQL) {

public void setTransition(String issueKey, int transitionId) {
LOG.debug("Transition issue {} - transition id: {}", issueKey, transitionId);
final com.atlassian.jira.rest.client.api.domain.Issue issue = client.getIssueClient().getIssue(issueKey).claim();
client.getIssueClient().transition(issue, new TransitionInput(transitionId));
JSONObject body = new JSONObject()
.put("transition", new JSONObject().put("id", String.valueOf(transitionId)));

HTTPUtils.Response response = httpUtils.post(
apiUrl("/issue/" + issueKey + "/transitions"),
RequestBody.create(body.toString(), JSON),
headers
);

if (!response.isSuccessful()) {
throw new RuntimeException("Unable to transition issue " + issueKey + ": " + response.getBody());
}
}

private Issue convertToIssue(com.atlassian.jira.rest.client.api.domain.Issue issue) {
private Issue convertToIssue(JSONObject json) {
Issue result = new Issue();
result.setKey(issue.getKey());
result.setSummary(issue.getSummary());
result.setDescription(issue.getDescription());
result.setProjectKey(issue.getProject().getKey());
result.setType(issue.getIssueType().getName());
result.setPriority(issue.getPriority() == null ? "" : issue.getPriority().getName());
result.setStatus(issue.getStatus().getName());
final Iterable<Attachment> attachments = issue.getAttachments();
result.setKey(json.getString("key"));

JSONObject fields = json.getJSONObject("fields");
result.setSummary(fields.optString("summary", ""));
result.setDescription(fields.isNull("description") ? "" : extractTextFromAdf(fields.optJSONObject("description")));
result.setType(fields.optJSONObject("issuetype") != null ? fields.getJSONObject("issuetype").optString("name", "") : "");
result.setPriority(fields.optJSONObject("priority") != null ? fields.getJSONObject("priority").optString("name", "") : "");
result.setStatus(fields.optJSONObject("status") != null ? fields.getJSONObject("status").optString("name", "") : "");
result.setProjectKey(fields.optJSONObject("project") != null ? fields.getJSONObject("project").optString("key", "") : "");

JSONArray attachments = fields.optJSONArray("attachment");
if (attachments != null) {
result.setAttachmentsIds(StreamSupport.stream(attachments.spliterator(), false).map(Attachment::getFilename).toList());
List<String> attachmentNames = new ArrayList<>();
for (int i = 0; i < attachments.length(); i++) {
attachmentNames.add(attachments.getJSONObject(i).optString("filename", ""));
}
result.setAttachmentsIds(attachmentNames);
} else {
result.setAttachmentsIds(List.of());
}

return result;
}

private String extractTextFromAdf(JSONObject adf) {
if (adf == null) {
return "";
}
StringBuilder sb = new StringBuilder();
JSONArray content = adf.optJSONArray("content");
if (content != null) {
for (int i = 0; i < content.length(); i++) {
JSONObject node = content.getJSONObject(i);
if ("text".equals(node.optString("type"))) {
sb.append(node.optString("text", ""));
} else {
sb.append(extractTextFromAdf(node));
}
}
}
if ("text".equals(adf.optString("type"))) {
sb.append(adf.optString("text", ""));
}
return sb.toString();
}
}
Loading