|
| 1 | +/** |
| 2 | + * A modern CFML component for interacting with the Jira Cloud REST API (v3). |
| 3 | + * This component uses API token-based authentication and handles pagination correctly. |
| 4 | + */ |
| 5 | +component { |
| 6 | + |
| 7 | + /** |
| 8 | + * Constructor for the JiraCloud component. |
| 9 | + * |
| 10 | + * @param config A struct containing the Jira connection details. |
| 11 | + * - domain: Your Jira domain (e.g., "your-company.atlassian.net"). |
| 12 | + * - email: The email address of the user for authentication. |
| 13 | + * - apiToken: The API token generated from the user's Atlassian account. |
| 14 | + */ |
| 15 | + public function init(required struct config) { |
| 16 | + variables.config = arguments.config; |
| 17 | + |
| 18 | + // Validate required config keys |
| 19 | + var requiredKeys = ["domain"]; |
| 20 | + for (var key in requiredKeys) { |
| 21 | + if (!structKeyExists(variables.config, key) || isEmpty(variables.config[key])) { |
| 22 | + throw(type="Jira.ConfigurationException", message="Missing required configuration key: #key#"); |
| 23 | + } |
| 24 | + } |
| 25 | + |
| 26 | + // Setup auth token only if email and apiToken are provided |
| 27 | + if (structKeyExists(variables.config, "email") && !isEmpty(variables.config.email) && |
| 28 | + structKeyExists(variables.config, "apiToken") && !isEmpty(variables.config.apiToken)) { |
| 29 | + variables.config.baseAuthToken = ToBase64(variables.config.email & ":" & variables.config.apiToken, "utf-8"); |
| 30 | + } else { |
| 31 | + variables.config.baseAuthToken = ""; |
| 32 | + } |
| 33 | + |
| 34 | + variables.config.baseUrl = "https://" & variables.config.domain & "/rest/api/3"; |
| 35 | + |
| 36 | + return this; |
| 37 | + } |
| 38 | + |
| 39 | + /** |
| 40 | + * Searches for issues using a JQL query. |
| 41 | + * This method handles pagination automatically and returns a complete array of issues. |
| 42 | + * Uses the new /search/jql endpoint (as of August 2025, /search is deprecated). |
| 43 | + * |
| 44 | + * @param jql The JQL query string. |
| 45 | + * @param fields An array of fields to return for each issue. Defaults to a common set. |
| 46 | + * @param maxResults The number of issues to return per page. |
| 47 | + */ |
| 48 | + public array function searchIssues( |
| 49 | + required string jql, |
| 50 | + array fields = ["summary", "status", "issuetype", "created", "updated", "priority", "fixVersions", "labels"], |
| 51 | + numeric maxResults = 1000 |
| 52 | + ) { |
| 53 | + var allIssues = []; |
| 54 | + var nextPageToken = ""; |
| 55 | + var pageNum = 1; |
| 56 | + var maxPages = 50; // Safety limit to prevent infinite loops |
| 57 | + |
| 58 | + do { |
| 59 | + var body = { |
| 60 | + "jql" = arguments.jql, |
| 61 | + "fields" = arguments.fields, |
| 62 | + "maxResults" = arguments.maxResults |
| 63 | + }; |
| 64 | + |
| 65 | + // Add nextPageToken for pagination if we have one |
| 66 | + if ( len( nextPageToken ) ) { |
| 67 | + body[ "nextPageToken" ] = nextPageToken; |
| 68 | + } |
| 69 | + |
| 70 | + // Make the request using the new /search/jql endpoint |
| 71 | + var result = _request( method="POST", path="/search/jql", body=body ); |
| 72 | + |
| 73 | + // Add the fetched issues to our main array |
| 74 | + if ( isStruct( result ) && structKeyExists( result, "issues" ) && isArray( result.issues ) ) { |
| 75 | + arrayAppend( allIssues, result.issues, true ); |
| 76 | + } |
| 77 | + |
| 78 | + // Check if we need to continue paging using nextPageToken |
| 79 | + nextPageToken = result.nextPageToken ?: ""; |
| 80 | + pageNum++; |
| 81 | + |
| 82 | + } while ( len( nextPageToken ) && pageNum <= maxPages ); |
| 83 | + |
| 84 | + return allIssues; |
| 85 | + } |
| 86 | + |
| 87 | + /** |
| 88 | + * Private helper function to execute HTTP requests to the Jira API. |
| 89 | + */ |
| 90 | + private struct function _request( |
| 91 | + required string method, |
| 92 | + required string path, |
| 93 | + struct body = {} |
| 94 | + ) { |
| 95 | + var fullUrl = variables.config.baseUrl & arguments.path; |
| 96 | + |
| 97 | + cfhttp( method=arguments.method, url=fullUrl, result="local.result", throwOnError=false ) { |
| 98 | + // Add Authorization header only if a token is available |
| 99 | + if ( len( variables.config.baseAuthToken ) ) { |
| 100 | + cfhttpparam( type="header", name="Authorization", value="Basic " & variables.config.baseAuthToken ); |
| 101 | + } |
| 102 | + cfhttpparam( type="header", name="Accept", value="application/json" ); |
| 103 | + |
| 104 | + if ( structCount( arguments.body ) ) { |
| 105 | + cfhttpparam( type="header", name="Content-Type", value="application/json" ); |
| 106 | + cfhttpparam( type="body", value=serializeJson( arguments.body ) ); |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + if ( local.result.statusCode != "200 OK" ) { |
| 111 | + throw( |
| 112 | + type="HTTPException", |
| 113 | + message="#local.result.statusCode#", |
| 114 | + detail="#arguments.method# #fullUrl#" & chr( 10 ) & "Response: " & local.result.fileContent |
| 115 | + ); |
| 116 | + } |
| 117 | + |
| 118 | + return deserializeJson( local.result.fileContent ); |
| 119 | + } |
| 120 | + |
| 121 | +} |
0 commit comments