Custom Issue Tracking Systems

FlexDeploy has out-of-box integrations with Jira, Redmine, Azure Boards, GitLab, and GitHub. You can easily integrate with other issue tracking systems as well. Such third-party Issue Tracking System integrations can be enabled using Java or Groovy implementation. Several ITS templates are shipped with FlexDeploy, but you can create more to connect to other systems that aren’t supported with a vanilla install.

Go to Issue Tracking Systems page using Configuration Integrations Issue Tracking menu.

Let's look at more details on how to create a custom Issue Tracking System Provider with FlexDeploy. Click Create to implement an integration with custom issue tracking system.

Go to the ITS Providers screen.

Click create, or click on an existing row if you are making changes to an existing ITS Provider.

  • Provide a unique name and optionally description

  • Define properties for the new issue tracking system. Properties are configuration values used by FlexDeploy to connect to the new system. The values of the properties that you define here are set on Issue Tracking Instances, folders, and projects.

    • If you define properties, you can indicate display and validation details. You can also indicate if a property is required and/or encrypted.

    • Enter a unique Name before adding any properties

  • Provide either Java Implementation or Groovy API. If both are supplied, the Java Implementation is used. Java implementation means that you are giving the classpath for a java class which you are placing the jar for in the libext folder. Groovy is defined on the Groovy Api tab. A Groovy script would allow for dynamic update, whereas use of Java code will require restart of server.

  • Click Save.

Here we are editing a custom issue tracking system with properties. You can add existing properties using the drop down on the create button.

API Implementation

Your Implementation must extend IssueTrackingSystem in order to be used by FlexDeploy. If you are not going to allow calling a method, you can leave the body blank, or throw an error in the body, but the method must be defined. If you choose to make a Java implementation, your IDE will verify that you have implemented all the required methods. If you choose the Groovy implementation, then the FlexDeploy UI will validate that your class contains the necessary methods. Groovy implementations can be used even with 3rd party jars that you place in libext, and allow for easier iterations. The advantage of a Java implementation is a potentially tighter integration with source control.

Java Implementation

Here are the high level steps for a Java implementation. You can use any IDE to prepare this implementation. 

  • Create java class that extends flexagon.fd.model.integration.its.api.IssueTrackingSystem. Example shown below has testConnection method implemented which uses properties map to retrieve the configuration values to connect to the issue tracking system.

  • All properties defined are available in Map returned by getProperties method.

Java Example

package flexagon.fd.model.integration.its.impl.redmine; import flexagon.fd.model.integration.its.api.IssueTrackingSystem; import flexagon.fd.model.integration.its.api.WorkItem; import flexagon.fd.model.integration.its.api.WorkItemAttachment; import flexagon.fd.model.integration.its.api.WorkItemComment; import flexagon.fd.model.integration.its.api.WorkItemDetails; import flexagon.fd.model.integration.its.api.WorkItemStatus; import flexagon.fd.model.integration.its.api.WorkItemUpdate; import flexagon.fd.model.integration.util.ApiException; import flexagon.fd.model.integration.util.RestService; import flexagon.ff.common.core.exceptions.FlexCheckedException; import flexagon.ff.common.core.logging.FlexLogger; import flexagon.ff.common.core.rest.FlexRESTClient; import flexagon.ff.common.core.rest.FlexRESTClientResponse; import flexagon.ff.common.core.utils.FlexCommonUtils; import java.io.InputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonObject; import javax.json.JsonObjectBuilder; import javax.ws.rs.core.MediaType; public class RedmineIssueTrackingSystem extends IssueTrackingSystem { private static final String CLZ_NAM = RedmineIssueTrackingSystem.class.getName(); private static final FlexLogger LOG = FlexLogger.getLogger(CLZ_NAM); private static final String FDREDMINE_URL = "FDREDMINE_URL"; private static final String FDREDMINE_TICKET_URL_PATTERN = "FDREDMINE_TICKET_URL_PATTERN"; private static final String FDREDMINE_ISSUE = "{REDMINE_ISSUE}"; private static final String FDREDMINE_USER_NMAE = "FDREDMINE_USER_NMAE"; private static final String FDREDMINE_PASSWORD = "FDREDMINE_PASSWORD"; public RedmineIssueTrackingSystem() { super(); } private StringBuilder getURLBuilder() { StringBuilder urlBuilder = new StringBuilder((String) getProperties().get(FDREDMINE_URL)); return urlBuilder; } private String getPropertyForTicket(String propertyName, String pTicketNummber) { String origPropValue = (String) getProperties().get(propertyName); if (origPropValue != null) { return origPropValue.replace(FDREDMINE_ISSUE, pTicketNummber); } else { return null; } } private String getURLProperty(String propertyName, String pTicketNummber) { String methodName = "getURLProperty"; LOG.logFinestEntering(methodName); StringBuilder ticketURLBuilder = getURLBuilder(); String urlProperty = ticketURLBuilder.append(getPropertyForTicket(propertyName, pTicketNummber)).toString(); LOG.logFinestExiting(methodName, urlProperty); return urlProperty; } private String getHostNameURL() { return getURLBuilder().toString(); } private String getUserName() { return (String) getProperties().get(FDREDMINE_USER_NMAE); } private String getPassword() { return (String) getProperties().get(FDREDMINE_PASSWORD); } private FlexRESTClient getRestService() throws ApiException { return RestService.getService(getHostNameURL(), getUserName(), getPassword()); } private FlexRESTClient getTicketWebResource(String pTicketNummber) throws ApiException { String ticketPath = getPropertyForTicket(FDREDMINE_TICKET_URL_PATTERN, pTicketNummber); return getRestService().path(ticketPath); } public String getWorkItemStatus(WorkItem ticket) throws ApiException { String methodName = "getTicketStatus"; LOG.logFinestEntering(methodName, ticket.getNumber()); FlexRESTClientResponse response; try { response = RestService.checkResponse(getTicketWebResource(ticket.getNumber()).get()); } catch (FlexCheckedException e) { throw new ApiException(e); } JsonObject jsonObject = RestService.readJsonObject(response.getResponseString()); JsonObject issueObject = (JsonObject) jsonObject.get("issue"); String statusName = null; if (issueObject != null) { javax.json.JsonObject status = issueObject.getJsonObject("status"); if (status != null) { statusName = status.getString("name"); } } LOG.logFinestExiting(methodName, statusName); return statusName; } /* * Populates ticket's data (e.g. Description with summary) */ @Override public void populateWorkItem(WorkItem ticket) throws ApiException { String methodName = "populateWorkItem"; LOG.logFinestEntering(methodName, ticket); FlexRESTClientResponse response; try { response = RestService.checkResponse(getTicketWebResource(ticket.getNumber()).get()); } catch (FlexCheckedException e) { throw new ApiException(e); } JsonObject jsonObject = RestService.readJsonObject(response.getResponseString()); if (jsonObject != null) { javax.json.JsonObject issue = jsonObject.getJsonObject("issue"); if (issue != null) { ticket.setDescription(issue.getString("description")); javax.json.JsonObject tracker = issue.getJsonObject("tracker"); if (tracker != null) { ticket.setType(tracker.getString("name")); } } } LOG.logFinestExiting(methodName); } /* * Builds absolute ticket's URL */ @Override public String getWorkItemURL(WorkItem ticket) throws ApiException { String methodName = "getWorkItemURL"; LOG.logFinestEntering(methodName, ticket); String url = getURLProperty(FDREDMINE_TICKET_URL_PATTERN, ticket.getNumber()); url = url.replace(".json", ""); LOG.logFinestExiting(methodName, url); return url; } /* * Adds a comment to a ticket * */ @Override public void addCommentToWorkItem(WorkItem ticket, String comment) throws ApiException { String methodName = "addCommentToWorkItem"; LOG.logFinestEntering(methodName, ticket.getNumber(), comment); JsonObject commentJson = Json.createObjectBuilder().add("issue", Json.createObjectBuilder().add("notes", comment)).build(); FlexRESTClientResponse response; try { response = RestService.checkResponse(getTicketWebResource(ticket.getNumber()).mediatype(MediaType.APPLICATION_JSON_TYPE).put(commentJson.toString())); } catch (FlexCheckedException e) { throw new ApiException(e); } LOG.logFinestExiting(methodName); } @SuppressWarnings("oracle.jdeveloper.java.insufficient-catch-block") private int getStatusIdFromName(String pStatus) throws ApiException { String methodName = "getStatusIdFromName"; LOG.logFinestEntering(methodName, pStatus); int status_id = -1; try { String statusNumber = pStatus.split("##")[0]; // If user saves 10 or 10##In Progress, let's try to parse it as number status_id = Integer.parseInt(statusNumber); } catch (Exception nfe) { // not integer value, call API } if (status_id == -1) { // call API to do conversion try { FlexRESTClientResponse response = RestService.checkResponse(getRestService().path("/issue_statuses.json").get()); Map<String, Integer> statusMap = parseIssueStatuses(response.getResponseString()); if (statusMap.containsKey(pStatus)) { status_id = statusMap.get(pStatus); } } catch (ApiException apie) { throw apie; } catch (FlexCheckedException e) { throw new ApiException(e); } } LOG.logFinestExiting(methodName, status_id); return status_id; } private Map<String, Integer> parseIssueStatuses(String pJson) { String methodName = "parseIssueStatuses"; LOG.logFinestEntering(methodName, pJson); // Example output // { // "issue_statuses": [ // { // "id": 1, // "name": "New", // "is_closed": false // }, // { // "id": 2, // "name": "In Progress", // "is_closed": false // }, // { // "id": 3, // "name": "Resolved", // "is_closed": false // }, // { // "id": 4, // "name": "Closed", // "is_closed": true // }, // { // "id": 5, // "name": "Rejected", // "is_closed": true // }, // { // "id": 6, // "name": "Ready", // "is_closed": false // } // ] // } Map<String, Integer> map = new HashMap<>(); JsonObject jsonObject = RestService.readJsonObject(pJson); JsonArray ar = jsonObject.getJsonArray("issue_statuses"); for (int i = 0; i < ar.size(); i++) { String name = ar.getJsonObject(i).getString("name"); int id = ar.getJsonObject(i).getInt("id"); map.put(name, id); } LOG.logFinestExiting(methodName, map); return map; } /* * Changes ticket's status * */ @Override public void changeWorkItemStatusTo(WorkItem ticket, Serializable status) throws ApiException { String methodName = "changeWorkItemStatusTo"; LOG.logFinestEntering(methodName, ticket.getNumber(), status); // Redmine API needs id and not status name JsonObjectBuilder addStatus = Json.createObjectBuilder().add("notes", "Status updated to " + status); addStatus = addStatus.add("status_id", getStatusIdFromName((String) status)); JsonObject statusJson = Json.createObjectBuilder().add("issue", addStatus).build(); FlexRESTClientResponse response; try { response = RestService.checkResponse(getTicketWebResource(ticket.getNumber()).mediatype(MediaType.APPLICATION_JSON_TYPE).put(statusJson.toString())); } catch (FlexCheckedException e) { throw new ApiException(e); } LOG.logFinestExiting(methodName); } @Override public void checkConnection() throws ApiException { String methodName = "checkConnection"; LOG.logFineEntering(methodName); try { String aboutUrl = "/users/current.json"; LOG.logInfo(methodName, "Checking Redmine connection for user {0} with path {1}", getUserName(), getHostNameURL() + aboutUrl); FlexRESTClientResponse clientResponse = getRestService().path(aboutUrl).get(); LOG.logInfo(methodName, "Successfully invoked test connection URL {0}", clientResponse); int statusCode = clientResponse.getResponse().getStatusInfo().getStatusCode(); if (statusCode == 401) { throw new ApiException("Invalid credentials.", ""); } RestService.checkResponse(clientResponse); LOG.logInfo(methodName, "Test connection response code looks valid, check content of response"); RestService.readJsonObject(clientResponse.getResponseString()); LOG.logInfo(methodName, "Validated that JSON data was received from test connction URL invocation."); } catch (ApiException e) { LOG.logInfo(methodName, "ApiException in Test connection", e); throw e; } catch (Exception e) { LOG.logInfo(methodName, "Failed in Test connection", e); throw new ApiException("Connection failed. " + e.getMessage(), ""); } LOG.logFineExiting(methodName); } @Override public Collection<String> parseWorkItemNumberFromChangeLogs(List<String> pChangeLogMessages, List<String> pTicketPatternList) throws ApiException { String methodName = "parseWorkItemNumberFromChangeLogs"; LOG.logFinestEntering(methodName, pChangeLogMessages, pTicketPatternList); Collection<String> ticketNumberList = new ArrayList<String>(); Collection<String> parsedTicketNumbers = flexagon.fd.model.integration.its.util.ChangeLogParser.getParser().parse(pChangeLogMessages, pTicketPatternList); if (FlexCommonUtils.isNotEmpty(parsedTicketNumbers) && FlexCommonUtils.isNotEmpty(pTicketPatternList)) { for (String parsedTicketWithPattern: parsedTicketNumbers) { for (String pattern: pTicketPatternList) { if (parsedTicketWithPattern.startsWith(pattern)) { String ticketNumber = parsedTicketWithPattern.substring(pattern.length(), parsedTicketWithPattern.length()); ticketNumberList.add(ticketNumber); } } } } LOG.logFinestExiting(methodName); return ticketNumberList; } @Override public Collection<WorkItemComment> getWorkItemComments(WorkItem pTicket, int i, int i2) throws ApiException { // TODO Implement this method return Collections.emptySet(); } @Override public Collection<WorkItemUpdate> getWorkItemHistory(WorkItem pTicket, int i, int i2) throws ApiException { // TODO Implement this method return Collections.emptySet(); } @Override public Collection<WorkItemAttachment> getWorkItemAttachments(WorkItem pTicket) throws ApiException { // TODO Implement this method return Collections.emptySet(); } @Override public WorkItemDetails getWorkItem(WorkItem pTicket) throws ApiException { // TODO Implement this method return null; } @Override public InputStream getWorkItemAttachmentContent(WorkItem pTicket, Serializable pSerializable) throws ApiException { // TODO Implement this method return null; } @Override public List<WorkItemDetails> getWorkItems(List<WorkItem> pList) throws ApiException { // TODO Implement this method return Collections.emptyList(); } @Override public List<WorkItemStatus> getAvailableWorkItemStatuses(WorkItem pWorkItem) throws ApiException { // TODO Implement this method return Collections.emptyList(); } }

Groovy Implementation

Here are the high level steps for a Groovy implementation. You can use any IDE to prepare this implementation. 

As groovy is able to access FlexDeploy variables and Java classes, you can take advantage of Java libraries from Groovy script. For example, if there is Java library used to access the issue tracking system, you can places those in lib folder and use those classes from Groovy script. This allows you to keep dynamic part of implementation in Groovy and use Java library.

Groovy Example
import flexagon.fd.model.integration.its.api.WorkItem; import flexagon.fd.model.integration.its.api.WorkItemAttachment; import flexagon.fd.model.integration.its.api.WorkItemComment; import flexagon.fd.model.integration.its.api.WorkItemDetails; import flexagon.fd.model.integration.its.api.WorkItemStatus; import flexagon.fd.model.integration.its.api.WorkItemUpdate; import flexagon.fd.model.integration.util.ApiException; class RedmineIssueTrackingSystem extends IssueTrackingSystem { void checkConnection() { String methodName = "checkConnection()"; try { fdrestutils.testConnection(REDMINE_URL, "/projects/1/versions.json", REDMINE_USER_NMAE, REDMINE_PASSWORD) } catch (Exception e) { log.logInfo(methodName, " Failed in Test connection" + e.getMessage() + " " + e) throw new ApiException("Connection failed. " + e.getMessage()); } } String getWorkItemStatus(WorkItem ticket) { String methodName = "getWorkItemStatus()"; String message = " ticket# ${ticket.toString()}" log.logInfo(methodName, message); String resourcePath = REDMINE_TICKET_URL_PATTERN.replaceAll("\\{REDMINE_ISSUE\\}", ticket.getNumber()) javax.json.JsonObject jsonObj = fdrestutils.getRequest(REDMINE_URL, resourcePath, REDMINE_USER_NMAE, REDMINE_PASSWORD); if(jsonObj != null) { javax.json.JsonObject issue = jsonObj.getJsonObject("issue") if(issue!=null) { javax.json.JsonObject status = issue.getJsonObject("status") if(status !=null) { return status.getString("name") } } } return null; } String getWorkItemURL(WorkItem ticket) { String methodName = "getWorkItemURL()"; String message = "ticket# ${ticket.toString()}" log.logInfo(methodName, message); String resourcePath = REDMINE_TICKET_URL_PATTERN.replaceAll("\\{REDMINE_ISSUE\\}", ticket.getNumber()) message = "Redmine ticket URL is # ${REDMINE_URL}, resourcePath=${resourcePath}" log.logInfo(methodName, message) return REDMINE_URL + resourcePath; } void populateWorkItem(WorkItem ticket) { String methodName = "populateWorkItem()"; String message = "Populate ticket# ${ticket.toString()}" log.logInfo(methodName, message) String resourcePath = REDMINE_TICKET_URL_PATTERN.replaceAll("\\{REDMINE_ISSUE\\}", ticket.getNumber()) javax.json.JsonObject jsonObj = fdrestutils.getRequest(REDMINE_URL, resourcePath, REDMINE_USER_NMAE, REDMINE_PASSWORD); if(jsonObj != null) { javax.json.JsonObject issue = jsonObj.getJsonObject("issue") if(issue!=null) { ticket.setDescription(issue.getString("description")) javax.json.JsonObject tracker = issue.getJsonObject("tracker") if(tracker !=null) { ticket.setType(tracker.getString("name")) } } } } void addCommentToWorkItem(WorkItem ticket, String pComment) { String methodName = "addCommentToWorkItem()"; try { String message = " Adding comment to ${ticket.toString()} , comment=${pComment}" log.logInfo(methodName, message) def builder = new groovy.json.JsonBuilder() def root = builder.issue { notes "${pComment}" } String payload = builder.toString(); String resourcePath = REDMINE_TICKET_REST_PATTERN.replaceAll("\\{REDMINE_ISSUE\\}", ticket.getNumber()) fdrestutils.putRequest(REDMINE_URL, REDMINE_USER_NMAE, REDMINE_PASSWORD, resourcePath, payload); } catch (Exception e) { log.logInfo( methodName, " Failed while adding comment to the issue - " + e.getMessage() + " " + e) throw new ApiException("Connection failed. " + e.getMessage()); } } void changeWorkItemStatusTo(WorkItem ticket, Serializable pStatus) { String methodName = "changeWorkItemStatusTo()"; String message = " Adding comment to ${ticket.toString()} , status=${pStatus}" log.logInfo(methodName, message) def builder = new groovy.json.JsonBuilder() def root = builder.issue { notes "Status updated to ${pStatus}" status_id "${pStatus}" } String payload = builder.toString(); String resourcePath = REDMINE_TICKET_REST_PATTERN.replaceAll("\\{REDMINE_ISSUE\\}", ticket.getNumber()) fdrestutils.putRequest(REDMINE_URL, REDMINE_USER_NMAE, REDMINE_PASSWORD, resourcePath, payload); } def parseWorkItemNumberFromChangeLogs(List<String> pChangeLogMessages, List<String> pTicketPatternList) { String methodName = "parseWorkItemNumberFromChangeLogs()"; Set<String> ticketNumberList = HashSet<String>(); String message = " Input ChangeLogMessages=${pChangeLogMessages} , TicketPatternList=${pTicketPatternList}" log.logInfo(methodName, message) Collection<String> parsedTicketNumbers = flexagon.fd.model.integration.its.util.ChangeLogParser.getParser().parse(pChangeLogMessages, pTicketPatternList); message = "parsedTicketNumbers=${parsedTicketNumbers}" log.logInfo(methodName, message) if(parsedTicketNumbers != null && !parsedTicketNumbers.isEmpty() && pTicketPatternList != null && !pTicketPatternList.isEmpty()) { parsedTicketNumbers.each{ parsedTicket -> pTicketPatternList.each{ pattern -> if(parsedTicket.startsWith(pattern)) { String ticketNumber = parsedTicket.substring(pattern.length(),parsedTicket.length()) ticketNumberList.add(ticketNumber) } } } } return ticketNumberList; } public Collection<WorkItemComment> getWorkItemComments(WorkItem pTicket, int i, int i2) throws ApiException { return Collections.emptySet(); } public Collection<WorkItemUpdate> getWorkItemHistory(WorkItem pTicket, int i, int i2) throws ApiException { // TODO Implement this method return Collections.emptySet(); } public Collection<WorkItemAttachment> getWorkItemAttachments(WorkItem pTicket) throws ApiException { // TODO Implement this method return Collections.emptySet(); } public WorkItemDetails getWorkItem(WorkItem pTicket) throws ApiException { // TODO Implement this method return null; } public InputStream getWorkItemAttachmentContent(WorkItem pTicket, Serializable pSerializable) throws ApiException { // TODO Implement this method return null; } public List<WorkItemDetails> getWorkItems(List<WorkItem> pList) throws ApiException { // TODO Implement this method return Collections.emptyList(); } public List<WorkItemStatus> getAvailableWorkItemStatuses(WorkItem pWorkItem) throws ApiException { // TODO Implement this method return Collections.emptyList(); } }
  • Implement all the methods described in the table in the API Implementation section

  • For any failure connecting to the system or if any issues with the data, then you can throw exception. For example, throw new ApiException("Invalid credentials.", "");

Groovy Utilities

There are some utility variables provided by FlexDeploy that can be used in your custom Groovy code.

  • log is a FlexDeploy logger variable which should be used to log any information from the groovy class.

  • itsstatusmap is a map<id, name> which can be used for getting the ITS status names from status ids. This can be useful for getting and updating ticket statuses if the API for your custom issue tracking system uses status names only.

  • fdrestutils is a utility object available to use FlexDeploy API to invoke any REST API. See the Java docs for more details on the functions available.

    def builder = new groovy.json.JsonBuilder() def root = builder.issue { notes "${pComment}" } String payload = builder.toString() String ticketNumber = ticket.getNumber() String resourcePath = REDMINE_TICKET_REST_PATTERN.replaceAll("\\{REDMINE_ISSUE\\}", ticketNumber) fdrestutils.putRequest(REDMINE_URL, REDMINE_USER_NMAE, REDMINE_PASSWORD, resourcePath, payload)

     

The following macros are not currently supported in the footer:
  • style