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/...

        Responses are unwrapped and paged, up to -MaxQueryPages pages:
        - JQL search results (/rest/api/3/search/jql) are unwrapped to their issues and pages are
          followed through 'nextPageToken'. Other responses with an 'issues' property (for example
          the bulk create response, which also has 'errors') are returned unchanged.
        - Jira platform pages ('values' with 'isLast', 'nextPage', 'startAt' or 'maxResults') are
          unwrapped to their values and the 'nextPage' URL is followed.
        - Jira Service Management pages ('values' with 'isLastPage') are unwrapped to their values
          and the '_links.next' URL is followed.
        Pagination links are only followed when they point to the site set by Set-JiraContext.
        Use -Raw to get the response exactly as Jira sends it, without unwrapping or paging.

        Retries: HTTP 429 is retried for every method. Other 5xx errors are retried only for the
        idempotent methods Get, Put and Delete, so a Post (for example creating an issue) is never
        sent twice. At most three attempts are made; the wait honours the Retry-After header
        (capped at 60 seconds) and is otherwise 2, then 4 seconds.

        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.
        'search' is sent to the enhanced JQL search endpoint /rest/api/3/search/jql and does not
        take -Id.
    .PARAMETER Id
        The identifier appended to -Resource, for example an issue key. It is URL-encoded as one
        path segment, so it cannot add further path segments or a query string; use -URIPath for
        sub-resources such as /issue/PROJ-1/transitions.
    .PARAMETER Body
        The request body. A string is sent as it is (it should be JSON). Any other object, such
        as a hashtable, is converted to JSON (depth 20).
    .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.
    .PARAMETER Raw
        Returns the response exactly as Jira sends it: search results and pages are not unwrapped
        and only one request is made.
    .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.
    .EXAMPLE
        Invoke-JiraRequest -Method Post -URIPath '/issue/bulk' -Body @{ issueUpdates = $updates } -Raw

        Creates issues in bulk and returns the whole response, including its 'errors'.
    .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,

        [object]$Body,

        [hashtable]$Query,

        [string]$JQL,

        [ValidateRange(1, 1000)]
        [int]$MaxQueryPages = 10,

        [switch]$Raw
    )

    $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 -eq 'search') {
            # /rest/api/3/search was retired by Atlassian; the enhanced search uses nextPageToken paging
            if ($Id) {
                throw '-Id cannot be used with -Resource search. Use -JQL or -Query to search.'
            }
            $requestPath = '/search/jql'
        }
        elseif ($Resource) {
            $requestPath = "/$Resource"
            if ($Id) {
                # One encoded path segment: '/', '?', '#' and '..' cannot change the request
                $requestPath += '/' + [System.Uri]::EscapeDataString($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"
        # Only JQL search results are unwrapped to their issues (not bulk create responses)
        $isSearch = ($apiPath -split '\?')[0] -match '(?i)^/rest/api/(2|3|latest)/search(/jql)?/?$'
        $requestBody = $null
        if ($PSBoundParameters.ContainsKey('Body')) {
            if ($null -eq $Body -or $Body -is [string]) {
                $requestBody = $Body
            }
            else {
                $requestBody = ConvertTo-Json -InputObject $Body -Depth 20
            }
        }
        $isIdempotent = $Method -in @('Get', 'Put', 'Delete')
        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

        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'] = $requestBody
            }
            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]
                    }

                    # 429: the request was not processed, so any method can be retried.
                    # 5xx: only idempotent methods, a POST may already have created something.
                    $retryable = ($statusCode -eq 429) -or ($isIdempotent -and $statusCode -ge 500 -and $statusCode -le 599)
                    if ($retryable -and $attempt -lt $maxAttempts) {
                        $retryDelaySeconds = Get-JiraRetryDelay -Response $caught.Exception.Response -Attempt $attempt
                        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 ($Raw) {
                if ($null -ne $response) {
                    # -Raw: one request, the response exactly as received
                    , $response
                }
                break
            }
            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)
                $isPlatformPage = ($propertyNames -contains 'values') -and (
                    ($propertyNames -contains 'isLast') -or ($propertyNames -contains 'nextPage') -or
                    ($propertyNames -contains 'startAt') -or ($propertyNames -contains 'maxResults'))
                $isServiceDeskPage = ($propertyNames -contains 'values') -and ($propertyNames -contains 'isLastPage')
                $pageLink = $null

                if ($isSearch -and $propertyNames -contains 'issues') {
                    Write-Verbose 'Detected a search result object. Extracting issues.'
                    foreach ($item in @($response.issues)) {
                        if ($null -ne $item) {
                            $allResults.Add($item)
                        }
                    }
                    # nextPageToken is a query parameter only for GET; POST search takes it in the body
                    if ($Method -eq 'Get' -and $propertyNames -contains 'nextPageToken' -and $response.nextPageToken -and -not ($propertyNames -contains 'isLast' -and $response.isLast)) {
                        $queryParameters['nextPageToken'] = [string]$response.nextPageToken
                        $morePages = $true
                    }
                }
                elseif ($isServiceDeskPage) {
                    Write-Verbose 'Detected a Service Management page. Extracting values.'
                    foreach ($item in @($response.values)) {
                        if ($null -ne $item) {
                            $allResults.Add($item)
                        }
                    }
                    if (-not $response.isLastPage -and $response._links -and $response._links.next) {
                        $pageLink = [string]$response._links.next
                    }
                }
                elseif ($isPlatformPage) {
                    Write-Verbose 'Detected a page of values. Extracting values.'
                    foreach ($item in @($response.values)) {
                        if ($null -ne $item) {
                            $allResults.Add($item)
                        }
                    }
                    if (-not ($propertyNames -contains 'isLast' -and $response.isLast) -and $propertyNames -contains 'nextPage' -and $response.nextPage) {
                        $pageLink = [string]$response.nextPage
                    }
                }
                else {
                    $allResults.Add($response)
                }

                if ($pageLink) {
                    if ($pageLink.StartsWith("$baseUri/", [System.StringComparison]::OrdinalIgnoreCase)) {
                        $nextUri = $pageLink
                        $morePages = $true
                    }
                    else {
                        Write-Warning "Not following a pagination link outside '$baseUri'."
                    }
                }
            }
            $pageCount++
        } while ($morePages -and $pageCount -lt $MaxQueryPages)

        if ($Raw) {
            Invoke-TelemetryCollection @TelemetryArgs -Stage End
            return
        }
        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
    }
}