Public/Invoke-JiraRequest.ps1
|
function Invoke-JiraRequest { <# .SYNOPSIS Sends a request to the Jira Cloud or Jira Service Management REST API. .DESCRIPTION Builds the request URL from the context set by Set-JiraContext, adds the Authorization header, sends the request and returns the parsed response. Path handling: - Paths starting with /rest/ are used as they are (for example /rest/agile/1.0/board). - Paths starting with /servicedeskapi/ are sent to /rest/servicedeskapi/... - Any other path is sent to the Jira platform API, /rest/api/3/... Search results (objects with an 'issues' property) are unwrapped to the issues. Pages are followed through 'nextPageToken' (JQL search) or a 'nextPage' URL on the same site, up to -MaxQueryPages. Requests that fail with HTTP 429 or 503 are retried up to three times. Errors are terminating and include the HTTP status and Jira's error details, never the credentials. .PARAMETER Method The HTTP method: Get, Post, Put, Delete or Patch. .PARAMETER URIPath The API path, for example /issue/JRA-9 or /servicedeskapi/request/SD-1. Takes precedence over -Resource and -JQL. .PARAMETER Resource A Jira platform resource shortcut (issue, project, search, user or group), combined with -Id. .PARAMETER Id The identifier appended to -Resource, for example an issue key. .PARAMETER Body The JSON request body. It is sent as UTF-8. .PARAMETER Query Query string parameters. Keys and values are URL-encoded. The hashtable is not modified. .PARAMETER JQL A JQL query. Without -URIPath the request goes to the enhanced search endpoint /rest/api/3/search/jql and returns all navigable fields unless -Query sets 'fields'. .PARAMETER MaxQueryPages The maximum number of pages to request. Defaults to 10. .EXAMPLE Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-123' Gets an issue. .EXAMPLE Invoke-JiraRequest -Method Get -JQL 'project = PROJ AND status = "In Progress"' -Query @{ maxResults = 100 } Returns the issues that match a JQL query. .EXAMPLE Invoke-JiraRequest -Method Get -URIPath '/servicedeskapi/request/SD-42' Gets a Jira Service Management request. .OUTPUTS The parsed response objects. #> [CmdletBinding()] [OutputType([object])] param ( [Parameter(Mandatory = $true)] [ValidateSet('Get', 'Post', 'Put', 'Delete', 'Patch')] [string]$Method, [Parameter(HelpMessage = 'Explicit URI path (e.g., /issue/JRA-9). Takes precedence over -Resource.')] [string]$URIPath, [Parameter(HelpMessage = 'High-level resource shortcut.')] [ValidateSet('issue', 'project', 'search', 'user', 'group')] [string]$Resource, [string]$Id, [string]$Body, [hashtable]$Query, [string]$JQL, [ValidateRange(1, 1000)] [int]$MaxQueryPages = 10 ) $TelemetryArgs = @{ ModuleName = $MyInvocation.MyCommand.Module.Name ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version CommandName = $MyInvocation.MyCommand.Name ExecutionID = [guid]::NewGuid().ToString() } Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer try { if (-not $script:JiraContext -or -not $script:JiraCredential) { throw 'Jira context is not set. Run Set-JiraContext first.' } $baseUri = ([string]$script:JiraContext.ConnectionURI).TrimEnd('/') # --- Request path --- if ($URIPath) { $requestPath = $URIPath } elseif ($JQL) { $requestPath = '/search/jql' } elseif ($Resource) { $requestPath = "/$Resource" if ($Id) { $requestPath += "/$Id" } Write-Verbose "Constructed request path from -Resource: $requestPath" } else { throw 'Either -URIPath, -Resource or -JQL must be specified.' } if (-not $requestPath.StartsWith('/')) { $requestPath = "/$requestPath" } if ($requestPath -like '/rest/*') { $apiPath = $requestPath } elseif ($requestPath -like '/servicedeskapi/*') { $apiPath = "/rest$requestPath" } else { $apiPath = "/rest/api/3$requestPath" } $endpointBase = "$baseUri$apiPath" Write-Verbose "Endpoint base (pre-query): $endpointBase" # --- Query parameters (copied so the caller's hashtable is not changed) --- $queryParameters = [ordered]@{} if ($Query) { foreach ($key in $Query.Keys) { $queryParameters[[string]$key] = $Query[$key] } } if ($JQL) { $queryParameters['jql'] = $JQL if ($apiPath -like '*/search/jql' -and -not $queryParameters.Contains('fields')) { # The enhanced search endpoint only returns issue ids unless fields are requested $queryParameters['fields'] = '*navigable' } } $headers = @{ Authorization = Get-JiraAuthorizationHeader Accept = 'application/json' } $allResults = New-Object -TypeName 'System.Collections.Generic.List[object]' $nextUri = $null $pageCount = 0 $maxAttempts = 3 $retryDelaySeconds = 2 do { if ($nextUri) { $uri = $nextUri } else { $pairs = @(foreach ($key in $queryParameters.Keys) { '{0}={1}' -f [System.Uri]::EscapeDataString([string]$key), [System.Uri]::EscapeDataString([string]$queryParameters[$key]) }) $uri = $endpointBase if ($pairs.Count -gt 0) { $uri = '{0}?{1}' -f $endpointBase, ($pairs -join '&') } } $requestParameters = @{ Uri = $uri Method = $Method Headers = $headers ContentType = 'application/json; charset=utf-8' ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { $requestParameters['Body'] = $Body } Write-Verbose ('[Page {0}] {1} {2}' -f ($pageCount + 1), $Method.ToUpperInvariant(), $uri) $attempt = 0 $response = $null while ($true) { $attempt++ try { $response = Invoke-RestMethod @requestParameters break } catch { $caught = $_ $statusCode = $null if ($caught.Exception.Response -and $caught.Exception.Response.StatusCode) { $statusCode = [int]$caught.Exception.Response.StatusCode } elseif ($caught.ErrorDetails -and $caught.ErrorDetails.Message -match '"status"\s*:\s*(\d+)') { $statusCode = [int]$Matches[1] } if (($statusCode -eq 429 -or $statusCode -eq 503) -and $attempt -lt $maxAttempts) { Write-Warning "Jira returned HTTP $statusCode. Retrying in $retryDelaySeconds seconds (attempt $attempt of $maxAttempts)." Start-Sleep -Seconds $retryDelaySeconds continue } $errorMessage = "Jira request $($Method.ToUpperInvariant()) '$uri' failed." if ($statusCode) { $errorMessage += " HTTP status code: $statusCode." } if ($caught.ErrorDetails -and $caught.ErrorDetails.Message) { $errorMessage += " Details: $($caught.ErrorDetails.Message)" } elseif ($caught.Exception.Response -is [System.Net.WebResponse]) { # Windows PowerShell: read the error body from the response stream try { $reader = New-Object -TypeName System.IO.StreamReader -ArgumentList $caught.Exception.Response.GetResponseStream() $errorMessage += " Details: $($reader.ReadToEnd())" $reader.Close() } catch { $errorMessage += " Details: $($_.Exception.Message)" } } else { $errorMessage += " Details: $($caught.Exception.Message)" } $exception = New-Object -TypeName System.InvalidOperationException -ArgumentList $errorMessage, $caught.Exception $errorRecord = New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList $exception, 'JiraRequestFailed', ([System.Management.Automation.ErrorCategory]::InvalidResult), $uri $PSCmdlet.ThrowTerminatingError($errorRecord) } } # --- Collect results --- $nextUri = $null $morePages = $false if ($response -is [array]) { foreach ($item in $response) { $allResults.Add($item) } } elseif ($null -ne $response -and -not ($response -is [string] -and $response.Length -eq 0)) { $propertyNames = @($response.PSObject.Properties.Name) if ($propertyNames -contains 'issues') { Write-Verbose 'Detected a search result object. Extracting issues.' foreach ($item in @($response.issues)) { if ($null -ne $item) { $allResults.Add($item) } } } else { $allResults.Add($response) } if ($propertyNames -contains 'nextPageToken' -and $response.nextPageToken -and -not ($propertyNames -contains 'isLast' -and $response.isLast)) { $queryParameters['nextPageToken'] = [string]$response.nextPageToken $morePages = $true } elseif ($propertyNames -contains 'nextPage' -and $response.nextPage) { $candidate = [string]$response.nextPage if ($candidate.StartsWith("$baseUri/", [System.StringComparison]::OrdinalIgnoreCase)) { $nextUri = $candidate $morePages = $true } else { Write-Warning "Not following a pagination link outside '$baseUri'." } } } $pageCount++ } while ($morePages -and $pageCount -lt $MaxQueryPages) if ($morePages) { Write-Warning "Stopped after $MaxQueryPages page(s); more results are available. Increase -MaxQueryPages to get them." } Write-Verbose "Response received. Total result objects collected: $($allResults.Count)" $allResults Invoke-TelemetryCollection @TelemetryArgs -Stage End } catch { Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_ throw } } |