diff --git a/src/com/ppm/integration/agilesdk/connector/jira/JIRAConstants.java b/src/com/ppm/integration/agilesdk/connector/jira/JIRAConstants.java index 40babea..044e1ce 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/JIRAConstants.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/JIRAConstants.java @@ -3,6 +3,8 @@ public class JIRAConstants { + public static final String JIRA_NAME_PREFIX = "#@#name#@#"; + public static final String NULL_VALUE = ""; public static final String REPLACE_PROJECT_KEY = "%PROJECT_KEY%"; @@ -83,10 +85,14 @@ public class JIRAConstants { public static final String API_VERSION2_API_ROOT = "/rest/api/2/"; + public static final String SEARCH_USER = API_VERSION2_API_ROOT + "user/search"; + public static final String API_VERSION1_API_ROOT = "/rest/agile/1.0/"; public static final String PROJECT_SUFFIX = API_VERSION2_API_ROOT + "project"; + public static final String CREATEMETA_SUFFIX = API_VERSION2_API_ROOT + "issue/createmeta"; + public static final String BOARD_SUFFIX = API_VERSION1_API_ROOT + "board"; public static final String SPRINT_SUFFIX = API_VERSION1_API_ROOT + "sprint"; @@ -124,7 +130,7 @@ public class JIRAConstants { public static final String JIRA_EPIC_NAME_CUSTOM = "com.pyxis.greenhopper.jira:gh-epic-label"; - public static final String JIRA_CREATE_ISSUE_URL = API_VERSION2_API_ROOT + "issue/"; + public static final String JIRA_REST_ISSUE_URL = API_VERSION2_API_ROOT + "issue/"; public static final String JIRA_GET_ISSUE_WORKLOG = API_VERSION2_API_ROOT + "issue/%issue%/worklog"; @@ -162,4 +168,6 @@ public class JIRAConstants { public static final String TS_DAILY_HOURS = "ts_daily_hours"; public static final String JIRA_FIELD_RESOLUTION_DATE = "resolutiondate"; + + } diff --git a/src/com/ppm/integration/agilesdk/connector/jira/JIRAIntegrationConnector.java b/src/com/ppm/integration/agilesdk/connector/jira/JIRAIntegrationConnector.java index efd12f0..d584311 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/JIRAIntegrationConnector.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/JIRAIntegrationConnector.java @@ -81,7 +81,7 @@ public List getIntegrations() { @Override public List getIntegrationClasses() { - return Arrays.asList(new String[] {"com.ppm.integration.agilesdk.connector.jira.JIRAWorkPlanIntegration","com.ppm.integration.agilesdk.connector.jira.JIRATimeSheetIntegration", "com.ppm.integration.agilesdk.connector.jira.JIRAPortfolioEpicIntegration", "com.ppm.integration.agilesdk.connector.jira.JIRAAgileDataIntegration"}); + return Arrays.asList(new String[] {"com.ppm.integration.agilesdk.connector.jira.JIRAWorkPlanIntegration","com.ppm.integration.agilesdk.connector.jira.JIRATimeSheetIntegration", "com.ppm.integration.agilesdk.connector.jira.JIRAPortfolioEpicIntegration", "com.ppm.integration.agilesdk.connector.jira.JIRAAgileDataIntegration", "com.ppm.integration.agilesdk.connector.jira.JIRARequestIntegration"}); } private DynamicDropdown getUserDataDDL(String elementName, diff --git a/src/com/ppm/integration/agilesdk/connector/jira/JIRAPortfolioEpicIntegration.java b/src/com/ppm/integration/agilesdk/connector/jira/JIRAPortfolioEpicIntegration.java index 92355fe..5adf429 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/JIRAPortfolioEpicIntegration.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/JIRAPortfolioEpicIntegration.java @@ -1,10 +1,7 @@ package com.ppm.integration.agilesdk.connector.jira; import com.ppm.integration.agilesdk.ValueSet; -import com.ppm.integration.agilesdk.connector.jira.model.JIRAEpic; -import com.ppm.integration.agilesdk.connector.jira.model.JIRAIssue; -import com.ppm.integration.agilesdk.connector.jira.model.JIRAProject; -import com.ppm.integration.agilesdk.connector.jira.model.JIRASubTaskableIssue; +import com.ppm.integration.agilesdk.connector.jira.model.*; import com.ppm.integration.agilesdk.model.AgileProject; import com.ppm.integration.agilesdk.epic.PortfolioEpicCreationInfo; import com.ppm.integration.agilesdk.epic.PortfolioEpicIntegration; @@ -40,13 +37,16 @@ public class JIRAPortfolioEpicIntegration extends PortfolioEpicIntegration { } // We want to retrieve the epic and all of its contents to be able to compute aggregated story points & percent SP complete + // That means retrieve all issue types except Sub-Tasks. + + List jiraIssueTypes = service.get(instanceConfigurationParameters).getProjectIssueTypes(agileProjectValue); Set issueTypes = new HashSet(); - issueTypes.add(JIRAConstants.JIRA_ISSUE_TASK); - issueTypes.add(JIRAConstants.JIRA_ISSUE_STORY); - issueTypes.add(JIRAConstants.JIRA_ISSUE_BUG); - issueTypes.add(JIRAConstants.JIRA_ISSUE_EPIC); - issueTypes.add(JIRAConstants.JIRA_ISSUE_FEATURE); + for (JIRAIssueType jiraIssueType : jiraIssueTypes) { + if (!JIRAConstants.JIRA_ISSUE_SUB_TASK.equalsIgnoreCase(jiraIssueType.getName())) { + issueTypes.add(jiraIssueType.getName().toUpperCase()); + } + } List issues = null; diff --git a/src/com/ppm/integration/agilesdk/connector/jira/JIRARequestIntegration.java b/src/com/ppm/integration/agilesdk/connector/jira/JIRARequestIntegration.java new file mode 100644 index 0000000..cb89d35 --- /dev/null +++ b/src/com/ppm/integration/agilesdk/connector/jira/JIRARequestIntegration.java @@ -0,0 +1,180 @@ +package com.ppm.integration.agilesdk.connector.jira; + +import com.ppm.integration.agilesdk.ValueSet; +import com.ppm.integration.agilesdk.connector.jira.model.JIRAFieldInfo; +import com.ppm.integration.agilesdk.connector.jira.model.JIRAIssueType; +import com.ppm.integration.agilesdk.dm.*; +import com.ppm.integration.agilesdk.model.*; +import org.apache.commons.lang.StringUtils; + +import java.util.*; + +import static com.ppm.integration.agilesdk.connector.jira.JIRAConstants.JIRA_NAME_PREFIX; + +public class JIRARequestIntegration extends RequestIntegration { + + private JIRAServiceProvider service = new JIRAServiceProvider().useAdminAccount(); + + @Override public List getAgileEntitiesInfo(String agileProjectValue, ValueSet instanceConfigurationParameters) { + + List issueTypes = service.get(instanceConfigurationParameters).getProjectIssueTypes(agileProjectValue); + + List entityList = new ArrayList(); + for (JIRAIssueType issueType : issueTypes) { + AgileEntityInfo feature = new AgileEntityInfo(); + feature.setName(issueType.getName()); + feature.setType(issueType.getId()); + entityList.add(feature); + } + + return entityList; + } + + @Override public List getAgileEntityFieldsInfo(String agileProjectValue, String entityType, + ValueSet instanceConfigurationParameters) + { + List fieldsInfo = new ArrayList<>(); + + List fields = new ArrayList(service.get(instanceConfigurationParameters).getFields(agileProjectValue, entityType).values()); + + for (JIRAFieldInfo field: fields) { + + // We support following JIRA fields types to map: + // - string + // - number + // - array type with non-empty allowedValues (i.e. drop down list) + // - User or array of User + // - priority (provided that allowedValues is non-empty) + + if ("string".equals(field.getType()) + || "number".equals(field.getType()) + || "user".equals(field.getType()) + || "priority".equals(field.getType()) + || ("array".equals(field.getType()))) { + + if (field.isList() && !"user".equals(field.getType()) && (field.getAllowedValues()== null || field.getAllowedValues().isEmpty())) { + // We only allow to select lists that have some static value options or are users lists. + continue; + } + + AgileEntityFieldInfo fieldInfo = new AgileEntityFieldInfo(); + fieldInfo.setId(field.getKey()); + fieldInfo.setLabel(field.getName()); + fieldInfo.setListType(field.isList()); + fieldInfo.setFieldType(field.getType()); + fieldsInfo.add(fieldInfo); + } + } + + return fieldsInfo; + } + + /*@Override public List getAgileEntityFieldValueList(String agileProjectValue, String entityType, String listIdentifier, + ValueSet instanceConfigurationParameters) + { + // Not used. + + List fields = service.get(instanceConfigurationParameters).getFields(agileProjectValue, entityType); + + for (JIRAFieldInfo field : fields) { + if (listIdentifier.equals(field.getKey())) { + return field.getAllowedValues(); + } + } + + // Field not found. + return new ArrayList(); + }*/ + + @Override public AgileEntity updateEntity(String agileProjectValue, String entityType, AgileEntity entity, + ValueSet instanceConfigurationParameters) + { + + JIRAServiceProvider.JIRAService jiraService = service.get(instanceConfigurationParameters); + + Map fields = getFieldsFromAgileEntity(entity, jiraService); + + + String issueKey = jiraService.updateIssue(agileProjectValue, entity.getId(), fields); + + return jiraService.getSingleAgileEntityIssue(agileProjectValue, issueKey); + } + + private Map getFieldsFromAgileEntity(AgileEntity entity, JIRAServiceProvider.JIRAService service) { + Map fields = new HashMap<>(); + + Iterator> fieldsIterator = entity.getAllFields(); + + while (fieldsIterator.hasNext()) { + Map.Entry field = fieldsIterator.next(); + + DataField dataField = field.getValue(); + + // As of PPM 9.50, data fields coming from PPM can be either simple string, simple User, or list of Users. + if (dataField == null) { + fields.put(field.getKey(), null); + } else if (DataField.DATA_TYPE.USER.equals(dataField.getType())) { + // This is one or more user; however, in JIRA the user fields usually only take one user - or at least that's how we'll synch them. + User user = null; + if (dataField.isList()) { + // PPM Multi user field + List users = (List)dataField.get(); + if (users != null && !users.isEmpty()) { + user = users.get(0); + } + } else { + // Single user + user = (User)dataField.get(); + } + + if (user == null) { + fields.put(field.getKey(), null); + } else { + // We need to retrieve the right Jira user matching the PPM user's email or username. + String jiraUsername = service.getJiraUsernameFromPpmUser(user); + fields.put(field.getKey(), jiraUsername == null ? null : JIRA_NAME_PREFIX + jiraUsername); + } + + } else { + // we consider it a single String value. + fields.put(field.getKey(), dataField.get() == null ? null : dataField.get().toString()); + } + } + + return fields; + } + + @Override public AgileEntity createEntity(String agileProjectValue, String entityType, AgileEntity entity, + ValueSet instanceConfigurationParameters) + { + JIRAServiceProvider.JIRAService jiraService = service.get(instanceConfigurationParameters); + + Map fields = getFieldsFromAgileEntity(entity, jiraService); + + String issueKey = jiraService.createIssue(agileProjectValue, fields, null, entityType); + + return jiraService.getSingleAgileEntityIssue(agileProjectValue, issueKey); + } + + @Override public List getEntities(String agileProjectValue, String entityType, + ValueSet instanceConfigurationParameters, Set entityIds, Date modifiedSinceDate) + { + + if (entityIds == null || entityIds.isEmpty()) { + return new ArrayList(); + } + + return service.get(instanceConfigurationParameters).getAgileEntityIssuesModifiedSince(entityIds, modifiedSinceDate); + } + + @Override public AgileEntity getEntity(String agileProjectValue, String entityType, + ValueSet instanceConfigurationParameters, String entityId) + { + if (StringUtils.isBlank(entityId)) { + return null; + } + + return service.get(instanceConfigurationParameters).getSingleAgileEntityIssue(agileProjectValue, entityId); + } + +} diff --git a/src/com/ppm/integration/agilesdk/connector/jira/JIRAServiceProvider.java b/src/com/ppm/integration/agilesdk/connector/jira/JIRAServiceProvider.java index 98a2c38..f382857 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/JIRAServiceProvider.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/JIRAServiceProvider.java @@ -8,7 +8,10 @@ import com.ppm.integration.agilesdk.connector.jira.rest.util.RestWrapper; import com.ppm.integration.agilesdk.connector.jira.rest.util.exception.RestRequestException; import com.ppm.integration.agilesdk.connector.jira.util.JiraIssuesRetrieverUrlBuilder; +import com.ppm.integration.agilesdk.dm.MultiUserField; +import com.ppm.integration.agilesdk.dm.StringField; import com.ppm.integration.agilesdk.epic.PortfolioEpicCreationInfo; +import com.ppm.integration.agilesdk.model.AgileEntity; import com.ppm.integration.agilesdk.provider.Providers; import com.ppm.integration.agilesdk.provider.UserProvider; import org.apache.commons.lang.StringUtils; @@ -121,11 +124,33 @@ public List getProjects() { list.add(project); } } catch (JSONException e) { - logger.error("", e); + logger.error("Error when retrieving Projects list", e); } return list; } + public List getProjectIssueTypes(String projectKey) { + ClientResponse response = wrapper.sendGet(baseUri + JIRAConstants.CREATEMETA_SUFFIX + "?projectKeys="+projectKey); + + String jsonStr = response.getEntity(String.class); + + List jiraIssueTypes = new ArrayList<>(); + try { + JSONObject result = new JSONObject(jsonStr); + JSONObject projectInfo = result.getJSONArray("projects").getJSONObject(0); + JSONArray issueTypes = projectInfo.getJSONArray("issuetypes"); + for (int i = 0; i < issueTypes.length(); i++) { + JSONObject issueType = issueTypes.getJSONObject(i); + JIRAIssueType jiraIssueType = JIRAIssueType.fromJSONObject(issueType); + + jiraIssueTypes.add(jiraIssueType); + } + } catch (JSONException e) { + logger.error("Error when retrieving Issues Types list for project "+projectKey, e); + } + return jiraIssueTypes; + } + public JIRAProject getProject(String projectKey) { ClientResponse response = wrapper.sendGet(baseUri + JIRAConstants.PROJECT_SUFFIX + "/" + projectKey); @@ -159,7 +184,6 @@ public List getAllSprints(String projectKey) { logger.error("Error when trying to retrieve JIRA Sprint information for Board ID " + board.getId() + " ('" + board.getName() + "'). Sprints from this board will be ignored."); } - } return jiraSprints; @@ -197,47 +221,123 @@ private List getBoardSprints(String boardId) { return jiraSprints; } - public String createEpic(String projectKey, PortfolioEpicCreationInfo epicInfo) { - // Read all custom field info required to create an Epic - initCustomFieldsInfo(); + /** + * @return the Issue Key (NOT the ID!) + */ + public String createIssue(String projectKey, Map fields, String issueTypeName, String issueTypeId) { - // Create Epic - String createEpicUri = baseUri + JIRAConstants.JIRA_CREATE_ISSUE_URL; + String createIssueUri = baseUri + JIRAConstants.JIRA_REST_ISSUE_URL; - JSONObject createEpicPayload = new JSONObject(); + JSONObject createIssuePayload = new JSONObject(); try { - JSONObject fields = new JSONObject(); + JSONObject fieldsObj = new JSONObject(); JSONObject project = new JSONObject(); project.put("key", projectKey); JSONObject issueType = new JSONObject(); - issueType.put("name", "Epic"); + if (!StringUtils.isBlank(issueTypeName)) { + issueType.put("name", issueTypeName); + } + if (!StringUtils.isBlank(issueTypeId)) { + issueType.put("id", issueTypeId); + } - fields.put("summary", epicInfo.getEpicName()); - fields.put(epicNameCustomField, epicInfo.getEpicName()); - fields.put("description", epicInfo.getEpicDescription()); - fields.put("project", project); - fields.put("issuetype", issueType); + fieldsObj.put("project", project); + fieldsObj.put("issuetype", issueType); - createEpicPayload.put("fields", fields); + setJiraJSonFields(fieldsObj, fields, projectKey, issueTypeId); + + createIssuePayload.put("fields", fieldsObj); } catch (JSONException e) { - throw new RuntimeException("Error when generating create Epic JSON Payload", e); + throw new RuntimeException("Error when generating create Issue JSON Payload", e); } - ClientResponse response = wrapper.sendPost(createEpicUri, createEpicPayload.toString(), 201); + ClientResponse response = wrapper.sendPost(createIssueUri, createIssuePayload.toString(), 201); String jsonStr = response.getEntity(String.class); try { JSONObject jsonObj = new JSONObject(jsonStr); return jsonObj.getString("key"); } catch (Exception e) { - throw new RuntimeException("Error while parsing Epic creation response from JIRA: " + jsonStr, e); + throw new RuntimeException("Error while parsing Issue creation response from JIRA: " + jsonStr, e); } } + + public String updateIssue(String projectKey, String issueKey, Map fields) { + + String updateIssueUri = baseUri + JIRAConstants.JIRA_REST_ISSUE_URL + issueKey; + + JSONObject updateIssuePayload = new JSONObject(); + + JIRAIssue issue = getIssues(projectKey, Arrays.asList(new String[] {issueKey})).get(0); + + try { + + JSONObject fieldsObj = new JSONObject(); + + setJiraJSonFields(fieldsObj, fields, projectKey, issue.getTypeId()); + + updateIssuePayload.put("fields", fieldsObj); + + } catch (JSONException e) { + throw new RuntimeException("Error when generating update Issue JSON Payload", e); + } + + ClientResponse response = wrapper.sendPut(updateIssueUri, updateIssuePayload.toString(), 204); + String jsonStr = response.getEntity(String.class); + + return issueKey; + } + + private void setJiraJSonFields(JSONObject fieldsObj, Map fields, String projectKey, + String issueTypeId) throws JSONException { + + // We need to know the types of the issue fields in order to parse them to number when needed. + Map fieldsInfo = getFields(projectKey, issueTypeId); + + + for (Map.Entry fieldEntry : fields.entrySet()) { + String fieldKey = fieldEntry.getKey(); + String value = fieldEntry.getValue(); + + JIRAFieldInfo fieldInfo = fieldsInfo.get(fieldKey); + + boolean isNumber = fieldInfo != null && "number".equals(fieldInfo.getType()); + + if (value == null || (isNumber && StringUtils.isBlank(value))) { + fieldsObj.put(fieldEntry.getKey(), (Object)null); + } else if (value.startsWith(JIRAConstants.JIRA_NAME_PREFIX)) { + value = value.substring(JIRAConstants.JIRA_NAME_PREFIX.length()); + JSONObject nameObj = new JSONObject(); + nameObj.put("name", value); + fieldsObj.put(fieldEntry.getKey(), nameObj); + } else { + if (isNumber) { + fieldsObj.put(fieldEntry.getKey(), Double.parseDouble(value)); + } else { + fieldsObj.put(fieldEntry.getKey(), value); + } + } + } + } + + public String createEpic(String projectKey, PortfolioEpicCreationInfo epicInfo) { + // Read all custom field info required to create an Epic + initCustomFieldsInfo(); + + Map fields = new HashMap<>(); + + fields.put("summary", epicInfo.getEpicName()); + fields.put(epicNameCustomField, epicInfo.getEpicName()); + fields.put("description", epicInfo.getEpicDescription()); + + return createIssue(projectKey, fields, "Epic", null); + } + /** * This method should be called only once on Service instantiation. */ @@ -407,31 +507,87 @@ private JiraIssuesRetrieverUrlBuilder decorateOrderBySprintCreatedUrl( } /** - * We use the search issue API instead of the /rest/issue/{key} because we already have all - * the logic to get the right columns and build the right JIRAIssue in the search API. + * This call gets an issue through /issue/ REST API and not through search. */ - public JIRAIssue getSingleIssue(String projectKey, String issueKey) { + public AgileEntity getSingleAgileEntityIssue(String projectKey, String issueKey) { + + AgileEntity entity = new AgileEntity(); + + entity.setId(issueKey); + + ClientResponse response = + wrapper.sendGet(baseUri + JIRAConstants.JIRA_REST_ISSUE_URL + issueKey); + + String jsonStr = response.getEntity(String.class); + + try { + JSONObject issueObj = new JSONObject(jsonStr); + return getAgileEntityFromIssueJSon(issueObj); + + } catch (JSONException e) { + throw new RuntimeException("Ërror when retrieving issue information for issue "+issueKey, e); + } + } + + public List getAgileEntityIssuesModifiedSince(Set entityIds, Date modifiedSinceDate) { JiraIssuesRetrieverUrlBuilder searchUrlBuilder = - new JiraIssuesRetrieverUrlBuilder(baseUri).setProjectKey(projectKey).setExpandLevel("schema") - .addAndConstraint("key=" + issueKey) - .addExtraFields(epicLinkCustomField, epicNameCustomField, sprintIdCustomField, - storyPointsCustomField); + new JiraIssuesRetrieverUrlBuilder(baseUri).retrieveAllFields(); - IssueRetrievalResult result = - runIssueRetrivalRequest(decorateOrderBySprintCreatedUrl(searchUrlBuilder).toUrlString()); + searchUrlBuilder.addAndConstraint("key in ("+StringUtils.join(entityIds, ",")+")"); + searchUrlBuilder.addAndConstraint("updated>='"+new SimpleDateFormat("yyyy-MM-dd HH:mm").format(modifiedSinceDate)+"'"); + + return retrieveAgileEntities(searchUrlBuilder); + } + + private void addJSONObjectFieldToEntity(String fieldKey, JSONObject field, AgileEntity entity) throws JSONException { + + if (isUserField(field)) { + Long ppmUserId = getPpmUserIdFromJiraUserField(field); + if (ppmUserId == null) { + entity.addField(fieldKey, null); + } else { + // PPM Only supports Multi User fields for now + MultiUserField muf = new MultiUserField(); + com.ppm.integration.agilesdk.dm.User user = new com.ppm.integration.agilesdk.dm.User(); + user.setUserId(ppmUserId); + List users = new ArrayList<>(1); + users.add(user); + muf.set(users); + entity.addField(fieldKey, muf); + } - if (result.getIssues().isEmpty()) { - return null; - } else if (result.getIssues().size() > 1) { - throw new RuntimeException( - "Retrieving issue " + issueKey + " in project " + projectKey + " returned more than 1 result (" - + result.getIssues().size() + ")"); } else { - return result.getIssues().get(0); + // Standard field. + String name = field.has("name") ? field.getString("name") : ""; + StringField sf = new StringField(); + // Since only strings are supported, we only set the Name, not the key. That will be for when CodeMeaning will be supported. + sf.set(name); + entity.addField(fieldKey, sf); } } + private Long getPpmUserIdFromJiraUserField(JSONObject field) throws JSONException { + String email = field.getString("emailAddress"); + + UserProvider provider = Providers.getUserProvider(JIRAIntegrationConnector.class); + User user = provider.getByEmail(email); + + if (user != null) { + return user.getUserId(); + } + + return null; + } + + private boolean isUserField(JSONObject field) throws JSONException { + return field != null && field.has("self") && field.has("emailAddress") && field.getString("self").contains("/user?"); + } + + private String getValueFromJsonObject(JSONObject jsonObject) throws JSONException { + return jsonObject.has("name") ? jsonObject.getString("name"):""; + } + /** * We use the search issue API instead of the /rest/issue/{key} because we already have all * the logic to get the right columns and build the right JIRAIssue in the search API. @@ -451,8 +607,13 @@ public List getIssues(String projectKey, Collection issueKeys IssueRetrievalResult result = runIssueRetrivalRequest(decorateOrderBySprintCreatedUrl(searchUrlBuilder).toUrlString()); + List issues = new ArrayList<>(); + + for (JSONObject obj : result.getIssues()) { + issues.add(getIssueFromJSONObj(obj)); + } - return result.getIssues(); + return issues; } public List getAllBoards(String projectKey) { @@ -515,6 +676,110 @@ public List getAllIssues(String projectKey, Set is return retrieveIssues(searchUrlBuilder); } + private List retrieveAgileEntities(JiraIssuesRetrieverUrlBuilder searchUrlBuilder) { + + IssueRetrievalResult result = null; + int fetchedResults = 0; + searchUrlBuilder.setStartAt(0); + + List allIssues = new ArrayList(); + + do { + result = runIssueRetrivalRequest(searchUrlBuilder.toUrlString()); + for (JSONObject obj : result.getIssues()) { + allIssues.add(getAgileEntityFromIssueJSon(obj)); + } + + fetchedResults += result.getMaxResults(); + searchUrlBuilder.setStartAt(fetchedResults); + + } while (fetchedResults < result.getTotal()); + + return allIssues; + } + + private AgileEntity getAgileEntityFromIssueJSon(JSONObject issueObj) { + + AgileEntity entity = new AgileEntity(); + + try { + JSONObject fieldsObj = issueObj.getJSONObject("fields"); + + for (String fieldKey : JSONObject.getNames(fieldsObj)) { + + if (fieldsObj.isNull(fieldKey)) { + // Null fields in JIRA are considered empty fields in PPM. + entity.addField(fieldKey, new StringField()); + continue; + } + + Object fieldContents = fieldsObj.get(fieldKey); + + if (fieldContents instanceof JSONObject) { + JSONObject field = (JSONObject)fieldContents; + addJSONObjectFieldToEntity(fieldKey, field, entity); + + } else if (fieldContents instanceof JSONArray) { + StringField sf = getStringFieldFromJsonArray((JSONArray)fieldContents); + entity.addField(fieldKey, sf); + + } else { + // If it's not an object nor an array, it's a string + StringField sf = new StringField(); + sf.set(fieldContents.toString()); + entity.addField(fieldKey, sf); + } + } + + if (fieldsObj.has("updated") && !fieldsObj.isNull("updated")) { + String updated = fieldsObj.getString("updated"); + + + if (!StringUtils.isBlank(updated)) { + + // JIRA will return dates with timezone offset not including colon (for example: +0800. However, XML Spec requires a colon, so let's add it. + if (updated.length() == 28) { + updated = updated.substring(0, 26) + ":" + updated.substring(26); + } + + entity.setLastUpdateTime(javax.xml.bind.DatatypeConverter.parseDateTime(updated).getTime()); + } + } + + if (issueObj.has("key") && !issueObj.isNull("key")) { + entity.setId(issueObj.getString("key")); + } + + entity.setEntityUrl(getBaseUrl()+ "/browse/"+entity.getId()); + + } catch (JSONException e) { + throw new RuntimeException("Error while parsing Issue JSon", e); + } + + return entity; + + } + + private StringField getStringFieldFromJsonArray(JSONArray jsonArray) throws JSONException { + + + List values = new ArrayList<>(); + + for (int i = 0; i < jsonArray.length(); i++) { + Object arrayValue = jsonArray.get(i); + if (arrayValue instanceof JSONObject) { + values.add(getValueFromJsonObject((JSONObject)arrayValue)); + } else if (arrayValue instanceof String) { + values.add((String)arrayValue); + } + // We don't support arrays in arrays. + } + + StringField sf = new StringField(); + sf.set(StringUtils.join(values, ";")); + + return sf; + } private List retrieveIssues(JiraIssuesRetrieverUrlBuilder searchUrlBuilder) { @@ -526,7 +791,9 @@ private List retrieveIssues(JiraIssuesRetrieverUrlBuilder do { result = runIssueRetrivalRequest(searchUrlBuilder.toUrlString()); - allIssues.addAll(result.getIssues()); + for (JSONObject issueObj : result.getIssues()) { + allIssues.add(getIssueFromJSONObj(issueObj)); + } fetchedResults += result.getMaxResults(); searchUrlBuilder.setStartAt(fetchedResults); @@ -626,9 +893,7 @@ private IssueRetrievalResult runIssueRetrivalRequest(String urlString) { for (int i = 0; i < issues.length(); i++) { JSONObject issueObj = issues.getJSONObject(i); - JIRAIssue issue = getIssueFromJSONObj(issueObj); - - result.addIssue(issue); + result.addIssue(issueObj); } return result; @@ -675,6 +940,8 @@ private JIRAIssue getIssueFromJSONObj(JSONObject obj) { // Common fields for all issues issue.setType(issueType); + issue.setTypeId(fields.getJSONObject("issuetype").getString("id")); + if (fields.has("status") && fields.getJSONObject("status").has("name")) { issue.setStatus(fields.getJSONObject("status").getString("name")); } @@ -1078,5 +1345,112 @@ private List pickWorklogs(List getFields(String projectKey, String issuetypeId) { + ClientResponse response = wrapper.sendGet(baseUri + JIRAConstants.CREATEMETA_SUFFIX + "?projectKeys="+projectKey+"&issuetypeIds="+issuetypeId+"&expand=projects.issuetypes.fields"); + + String jsonStr = response.getEntity(String.class); + + Map jiraFieldsInfo = new HashMap<>(); + try { + JSONObject result = new JSONObject(jsonStr); + JSONObject projectInfo = result.getJSONArray("projects").getJSONObject(0); + JSONObject fields = projectInfo.getJSONArray("issuetypes").getJSONObject(0).getJSONObject("fields"); + + for (String fieldKey : JSONObject.getNames(fields)) { + JSONObject field = fields.getJSONObject(fieldKey); + JIRAFieldInfo fieldInfo = JIRAFieldInfo.fromJSONObject(field, fieldKey); + jiraFieldsInfo.put(fieldInfo.getKey(), fieldInfo); + } + + } catch (JSONException e) { + logger.error("Error when retrieving Issues Type fields for project "+projectKey+" and issue type ID "+issuetypeId, e); + } + return jiraFieldsInfo; + + } + + public String getBaseUrl() { + return baseUri; + } + + /** + * In JIRA, it's possible to login with your email address but the actual user identifier will be different ; + * searches based on users must use the correct account username. + */ + public String getAccountUsernameFromLogonUsername(String logonUsername) { + + String accountUsernameFromEmailMatch = null; + + boolean hasMultipleEmailMatch = false; + + ClientResponse response = wrapper.sendGet(baseUri + JIRAConstants.SEARCH_USER + "?username="+logonUsername); + String jsonStr = response.getEntity(String.class); + + try { + JSONArray results = new JSONArray(jsonStr); + + for (int i = 0 ; i < results.length() ; i++) { + JSONObject userInfo = results.getJSONObject(i); + + if (logonUsername.equalsIgnoreCase(userInfo.getString("name"))) { + // Perfect match on username; + return logonUsername; + } + + if (logonUsername.equalsIgnoreCase(userInfo.getString("emailAddress"))) { + if (accountUsernameFromEmailMatch != null) { + // We have multiple users with the same email, so search by email is inconclusive. + hasMultipleEmailMatch = true; + } + + accountUsernameFromEmailMatch = userInfo.getString("name"); + } + } + + + + } catch (JSONException e) { + logger.error("Error when retrieving User account info for user "+logonUsername, e); + } + + return accountUsernameFromEmailMatch == null || hasMultipleEmailMatch ? logonUsername : accountUsernameFromEmailMatch; + } + + public String getJiraUsernameFromPpmUser(com.ppm.integration.agilesdk.dm.User user) { + // We search first by email, or by username, or by full name, whatever comes first. + String jiraUserSearch = user.getEmail(); + if (StringUtils.isBlank(jiraUserSearch)) { + jiraUserSearch = user.getUsername(); + } + if (StringUtils.isBlank(jiraUserSearch)) { + jiraUserSearch = user.getFullName(); + } + if (StringUtils.isBlank(jiraUserSearch)) { + return null; + } + + ClientResponse response = + wrapper.sendGet(baseUri + JIRAConstants.SEARCH_USER + "?username=" + jiraUserSearch); + String jsonStr = response.getEntity(String.class); + + try { + JSONArray results = new JSONArray(jsonStr); + + if (results.length() == 0) { + return null; + } + + // We only match with the first user returned. + JSONObject userInfo = results.getJSONObject(0); + + return userInfo.getString("name"); + + } catch (JSONException e) { + logger.error("Error when retrieving User account info for PPM user " + user.getUserId(), e); + } + + return null; + } } } diff --git a/src/com/ppm/integration/agilesdk/connector/jira/JIRATimeSheetIntegration.java b/src/com/ppm/integration/agilesdk/connector/jira/JIRATimeSheetIntegration.java index 5eacee2..65462e9 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/JIRATimeSheetIntegration.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/JIRATimeSheetIntegration.java @@ -47,6 +47,8 @@ public List getExternalWorkItems(TimeSheetIntegrationContext t String author = values.get(JIRAConstants.KEY_USERNAME); + author = s.getAccountUsernameFromLogonUsername(author); + String groupBy = values.get(JIRAConstants.TS_GROUP_WORK_BY); String importEffortBy = values.get(JIRAConstants.TS_IMPORT_HOURS_OPTION); String adjustHours = values.get(JIRAConstants.TS_ADJUST_HOURS); diff --git a/src/com/ppm/integration/agilesdk/connector/jira/model/IssueRetrievalResult.java b/src/com/ppm/integration/agilesdk/connector/jira/model/IssueRetrievalResult.java index b578180..6c85dc5 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/model/IssueRetrievalResult.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/model/IssueRetrievalResult.java @@ -1,11 +1,13 @@ package com.ppm.integration.agilesdk.connector.jira.model; +import org.json.JSONObject; + import java.util.ArrayList; import java.util.List; public class IssueRetrievalResult { - private List issues = new ArrayList<>(); + private List issues = new ArrayList<>(); private int startAt; @@ -19,11 +21,11 @@ public IssueRetrievalResult(int startAt, int maxResults, int total) { this.total = total; } - public List getIssues() { + public List getIssues() { return issues; } - public void addIssue(JIRAIssue issue) { + public void addIssue(JSONObject issue) { issues.add(issue); } diff --git a/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAEpic.java b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAEpic.java index 3f92c8d..60e8abc 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAEpic.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAEpic.java @@ -24,15 +24,21 @@ public void addContent(JIRASubTaskableIssue issue) { contents.add(issue); } - // Sums up the Story Points from all the Epic contents + // Sums up the Story Points from all the Epic contents, or use Epic SP if there's not content SP info. public int getAggregatedStoryPoints() { int aggSP = 0; + int epicSP = getStoryPoints() == null ? 0 : getStoryPoints().intValue(); for (JIRASubTaskableIssue content : contents) { aggSP += content.getStoryPoints() == null ? 0 : content.getStoryPoints(); } + if (aggSP == 0) { + // Epic story points are used if Epic hasn't yet been broken down & sized + aggSP = epicSP; + } + return aggSP; } diff --git a/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAFieldInfo.java b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAFieldInfo.java new file mode 100644 index 0000000..0f67dc1 --- /dev/null +++ b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAFieldInfo.java @@ -0,0 +1,129 @@ + +package com.ppm.integration.agilesdk.connector.jira.model; + +import com.ppm.integration.agilesdk.dm.DataField; +import com.ppm.integration.agilesdk.dm.StringField; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class JIRAFieldInfo { + + private String key; + + private String name; + + private boolean isList = false; + + private String type; + + private String system; + + private String items; + + private List allowedValues = null; + + + public static JIRAFieldInfo fromJSONObject(JSONObject obj, String key) { + try { + JIRAFieldInfo fieldInfo = new JIRAFieldInfo(); + fieldInfo.setKey(key); + fieldInfo.setName(obj.getString("name")); + + if (obj.has("allowedValues")) { + JSONArray allowedValues = obj.getJSONArray("allowedValues"); + if (allowedValues != null) { + fieldInfo.setAllowedValues(new ArrayList(allowedValues.length())); + + for (int i = 0 ; i < allowedValues.length() ; i++) { + DataField listValue = new StringField(); + JSONObject listValueObj = allowedValues.getJSONObject(i); + listValue.set(listValueObj.getString("name")); + fieldInfo.getAllowedValues().add(listValue); + } + } + } + + JSONObject schema = obj.getJSONObject("schema"); + + if (schema != null) { + if (schema.has("type")) { + fieldInfo.setType(schema.getString("type")); + } + if (schema.has("system")) { + fieldInfo.setSystem(schema.getString("system")); + } + if (schema.has("items")) { + fieldInfo.setItems(schema.getString("items")); + } + } + + if (fieldInfo.getAllowedValues() != null || "array".equals(fieldInfo.getType())) { + fieldInfo.setList(true); + } + + return fieldInfo; + } catch (JSONException e) { + throw new RuntimeException("Error while reading JSon defintion of Issue Type", e); + } + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isList() { + return isList; + } + + public void setList(boolean list) { + isList = list; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getSystem() { + return system; + } + + public void setSystem(String system) { + this.system = system; + } + + public String getItems() { + return items; + } + + public void setItems(String items) { + this.items = items; + } + + public List getAllowedValues() { + return allowedValues; + } + + public void setAllowedValues(List allowedValues) { + this.allowedValues = allowedValues; + } +} diff --git a/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssue.java b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssue.java index f252147..1f63f8b 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssue.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssue.java @@ -12,6 +12,8 @@ public abstract class JIRAIssue extends JIRAEntity { private String type; + private String typeId; + private String authorName; private String creationDate; @@ -251,4 +253,12 @@ public void setResolutionDate(String resolutionDate) { public String getResolutionDate() { return resolutionDate; } + + public String getTypeId() { + return typeId; + } + + public void setTypeId(String typeId) { + this.typeId = typeId; + } } diff --git a/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssueType.java b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssueType.java new file mode 100644 index 0000000..ce6aac9 --- /dev/null +++ b/src/com/ppm/integration/agilesdk/connector/jira/model/JIRAIssueType.java @@ -0,0 +1,51 @@ + +package com.ppm.integration.agilesdk.connector.jira.model; + +import org.json.JSONException; +import org.json.JSONObject; + +public class JIRAIssueType { + + private String id; + + private String name; + + private String description; + + + public static JIRAIssueType fromJSONObject(JSONObject obj) { + try { + JIRAIssueType issueType = new JIRAIssueType(); + issueType.setName(obj.getString("name")); + issueType.setId(obj.getString("id")); + issueType.setDescription(obj.getString("description")); + return issueType; + } catch (JSONException e) { + throw new RuntimeException("Error while reading JSon defintion of Issue Type", e); + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/src/com/ppm/integration/agilesdk/connector/jira/rest/util/RestWrapper.java b/src/com/ppm/integration/agilesdk/connector/jira/rest/util/RestWrapper.java index a50f37a..b43965c 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/rest/util/RestWrapper.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/rest/util/RestWrapper.java @@ -117,4 +117,19 @@ public ClientResponse sendPost(String uri, String jsonPayload, int expectedHttpS return response; } + + public ClientResponse sendPut(String uri, String jsonPayload, int expectedHttpStatusCode) { + Resource resource = this.getJIRAResource(uri); + ClientResponse response = resource.put(jsonPayload); + + int statusCode = response.getStatusCode(); + + if (statusCode != expectedHttpStatusCode) { + // for easier troubleshooting, include the request URI in the exception message + throw new RestRequestException(statusCode, + String.format("Unexpected HTTP response status code %s for %s, expected %s", statusCode, uri, expectedHttpStatusCode)); + } + + return response; + } } diff --git a/src/com/ppm/integration/agilesdk/connector/jira/util/JiraIssuesRetrieverUrlBuilder.java b/src/com/ppm/integration/agilesdk/connector/jira/util/JiraIssuesRetrieverUrlBuilder.java index d4244ed..61d7ed6 100644 --- a/src/com/ppm/integration/agilesdk/connector/jira/util/JiraIssuesRetrieverUrlBuilder.java +++ b/src/com/ppm/integration/agilesdk/connector/jira/util/JiraIssuesRetrieverUrlBuilder.java @@ -68,6 +68,14 @@ public JiraIssuesRetrieverUrlBuilder setBoardType(String boardId) { return this; } + public JiraIssuesRetrieverUrlBuilder retrieveAllFields() { + this.fields = new String[] {}; + extraFields = new ArrayList<>(); + addExtraFields("*all"); + + return this; + } + public String toUrlString() { @@ -105,11 +113,9 @@ public String toUrlString() { urlParameters.add("jql=" + jql); } - searchUrl.append(StringUtils.join(urlParameters, "&")); - - String url = encodeUrl(searchUrl.toString()); + searchUrl.append(encodeUrl(StringUtils.join(urlParameters, "&"))); - return url; + return searchUrl.toString(); } @@ -149,16 +155,11 @@ private String getJQLString() { } private String getIssueTypeConstraintJQL() { - if (issuesTypes == null || issuesTypes.isEmpty()) { return ""; - } - - if (issuesTypes != null && !issuesTypes.isEmpty()) { + } else { return " and issuetype in(" + StringUtils.join(issuesTypes, ",") + ") "; } - - return ""; } public JiraIssuesRetrieverUrlBuilder setProjectKey(String projectKey) {