Private/Connect-JiraSM.ps1

function Connect-JiraSM {
    <#
    .SYNOPSIS
        Executes authenticated REST API requests against Jira Service Management.
    .DESCRIPTION
        Handles Jira REST API authentication (email + API token via basic auth),
        JQL query construction, pagination, and rate limit handling.
        Returns parsed JSON response objects.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BaseUrl,

        [Parameter(Mandatory)]
        [string]$Email,

        [Parameter()]
        [string]$ApiToken,

        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory)]
        [string]$Endpoint,

        [Parameter()]
        [hashtable]$Body,

        [Parameter()]
        [hashtable]$QueryParameters,

        [Parameter()]
        [switch]$Paginate,

        [Parameter()]
        [int]$PageSize = 100,

        [Parameter()]
        [int]$MaxRecords = 10000,

        [Parameter()]
        [int]$MaxRetries = 3
    )

    # Resolve API token from environment if not provided
    if (-not $ApiToken) {
        if ($env:JIRA_API_TOKEN) {
            $ApiToken = $env:JIRA_API_TOKEN
        }
        else {
            throw 'No API token provided for Jira. Supply -ApiToken or set $env:JIRA_API_TOKEN.'
        }
    }

    # Build base URL
    $cleanBaseUrl = $BaseUrl.TrimEnd('/')

    # Build authentication headers (Jira uses basic auth with email:token)
    $pair = "${Email}:${ApiToken}"
    $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair)
    $base64 = [System.Convert]::ToBase64String($bytes)

    $headers = @{
        'Authorization' = "Basic $base64"
        'Accept'        = 'application/json'
        'Content-Type'  = 'application/json'
    }

    # Build the full URL
    $fullEndpoint = $Endpoint.TrimStart('/')
    $url = "$cleanBaseUrl/$fullEndpoint"

    # Add query parameters
    if ($QueryParameters -and $QueryParameters.Count -gt 0) {
        $queryParts = @()
        foreach ($key in $QueryParameters.Keys) {
            $encodedValue = [System.Uri]::EscapeDataString($QueryParameters[$key])
            $queryParts += "${key}=${encodedValue}"
        }
        $queryString = $queryParts -join '&'
        $url = "${url}?${queryString}"
    }

    # Non-paginated request
    if (-not $Paginate) {
        return Invoke-JiraRequestInternal -Url $url -Method $Method -Headers $headers -Body $Body -MaxRetries $MaxRetries
    }

    # Paginated search request (Jira uses startAt/maxResults)
    if ($Method -ne 'GET' -and $Method -ne 'POST') {
        Write-Warning 'Pagination is only supported for GET/POST search requests. Executing single request.'
        return Invoke-JiraRequestInternal -Url $url -Method $Method -Headers $headers -Body $Body -MaxRetries $MaxRetries
    }

    $allResults = @()
    $startAt = 0
    $hasMore = $true

    while ($hasMore -and $allResults.Count -lt $MaxRecords) {
        # For GET requests, append pagination to query string
        $separator = if ($url -match '\?') { '&' } else { '?' }
        $pageUrl = "${url}${separator}startAt=${startAt}&maxResults=${PageSize}"

        Write-Verbose "Jira paginated request: startAt=$startAt, maxResults=$PageSize"

        $response = Invoke-JiraRequestInternal -Url $pageUrl -Method $Method -Headers $headers -Body $Body -MaxRetries $MaxRetries

        if ($null -eq $response) {
            $hasMore = $false
            continue
        }

        # Jira search results have issues array and total count
        $issues = if ($response.PSObject.Properties['issues']) {
            $response.issues
        }
        elseif ($response.PSObject.Properties['values']) {
            $response.values
        }
        else {
            $response
        }

        if ($null -eq $issues -or @($issues).Count -eq 0) {
            $hasMore = $false
        }
        else {
            $batch = @($issues)
            $allResults += $batch
            $startAt += $batch.Count

            # Check if we've gotten all results
            $total = if ($response.PSObject.Properties['total']) { $response.total } else { 0 }
            if ($startAt -ge $total -or $batch.Count -lt $PageSize) {
                $hasMore = $false
            }

            Write-Verbose "Retrieved $($batch.Count) issues (total: $($allResults.Count) of $total)"
        }
    }

    if ($allResults.Count -ge $MaxRecords) {
        Write-Warning "Reached maximum record limit ($MaxRecords). Results may be incomplete."
    }

    return $allResults
}


function Invoke-JiraRequestInternal {
    <#
    .SYNOPSIS
        Internal helper for executing a single Jira REST API request with retry logic.
    #>

    [CmdletBinding()]
    param(
        [string]$Url,
        [string]$Method,
        [hashtable]$Headers,
        [hashtable]$Body,
        [int]$MaxRetries = 3
    )

    $attempt = 0
    $lastError = $null

    while ($attempt -lt $MaxRetries) {
        $attempt++
        try {
            $splat = @{
                Uri         = $Url
                Method      = $Method
                Headers     = $Headers
                ErrorAction = 'Stop'
            }

            if ($Body -and $Method -in @('POST', 'PUT')) {
                $jsonBody = $Body | ConvertTo-Json -Depth 10 -Compress
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    $splat['Body'] = [System.Text.Encoding]::UTF8.GetBytes($jsonBody)
                }
                else {
                    $splat['Body'] = $jsonBody
                }
            }

            Write-Verbose "Jira $Method $Url (attempt $attempt)"
            $response = Invoke-RestMethod @splat
            return $response
        }
        catch {
            $lastError = $_
            $statusCode = $null

            if ($_.Exception.Response) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }

            switch ($statusCode) {
                401 {
                    throw "Jira authentication failed (401). Check your email and API token. URL: $Url"
                }
                403 {
                    throw "Jira access denied (403). Insufficient permissions. URL: $Url"
                }
                404 {
                    Write-Warning "Jira resource not found (404): $Url"
                    return $null
                }
                429 {
                    $retryAfter = 10
                    if ($_.Exception.Response.Headers) {
                        try {
                            $retryHeader = $_.Exception.Response.Headers.GetValues('Retry-After')
                            if ($retryHeader) {
                                $retryAfter = [int]$retryHeader[0]
                            }
                        }
                        catch { }
                    }
                    Write-Warning "Jira rate limited (429). Waiting $retryAfter seconds (attempt $attempt/$MaxRetries)."
                    Start-Sleep -Seconds $retryAfter
                }
                default {
                    if ($attempt -lt $MaxRetries) {
                        $backoff = [math]::Pow(2, $attempt)
                        Write-Warning "Jira request failed (attempt $attempt/$MaxRetries). Retrying in $backoff seconds. Error: $($_.Exception.Message)"
                        Start-Sleep -Seconds $backoff
                    }
                }
            }
        }
    }

    throw "Jira request failed after $MaxRetries attempts. URL: $Url. Last error: $($lastError.Exception.Message)"
}


function ConvertFrom-JiraIssue {
    <#
    .SYNOPSIS
        Converts Jira issue objects to normalized ticket objects matching the ServiceNow schema.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]$Issue
    )

    process {
        $fields = $Issue.fields

        # Map Jira issue type to ITSM ticket type
        $ticketType = switch -Wildcard ($fields.issuetype.name) {
            '*Incident*'     { 'Incident' }
            '*Problem*'      { 'Problem' }
            '*Change*'       { 'Change Request' }
            '*Service*'      { 'Service Request' }
            '*Bug*'          { 'Incident' }
            '*Task*'         { 'Change Request' }
            default          { 'Incident' }
        }

        # Map Jira status to normalized state
        $state = switch -Wildcard ($fields.status.name) {
            '*Open*'         { 'Open' }
            '*New*'          { 'New' }
            '*Progress*'     { 'In Progress' }
            '*Review*'       { 'In Review' }
            '*Resolved*'     { 'Resolved' }
            '*Closed*'       { 'Closed' }
            '*Done*'         { 'Closed' }
            '*Cancelled*'    { 'Cancelled' }
            default          { $fields.status.name }
        }

        # Map Jira priority
        $priority = switch ($fields.priority.name) {
            'Highest' { '1 - Critical' }
            'High'    { '2 - High' }
            'Medium'  { '3 - Moderate' }
            'Low'     { '4 - Low' }
            'Lowest'  { '5 - Planning' }
            default   { '3 - Moderate' }
        }

        [PSCustomObject]@{
            Number           = $Issue.key
            Type             = $ticketType
            ShortDescription = $fields.summary
            Description      = $fields.description
            State            = $state
            Priority         = $priority
            Category         = if ($fields.components) { ($fields.components | Select-Object -First 1).name } else { '' }
            Subcategory      = ''
            OpenedAt         = $fields.created
            ClosedAt         = $fields.resolutiondate
            ResolvedAt       = $fields.resolutiondate
            AssignedTo       = if ($fields.assignee) { $fields.assignee.displayName } else { 'Unassigned' }
            CallerName       = if ($fields.reporter) { $fields.reporter.displayName } else { '' }
            CloseNotes       = if ($fields.resolution) { $fields.resolution.name } else { '' }
            WorkNotes        = if ($fields.comment -and $fields.comment.comments) {
                ($fields.comment.comments | ForEach-Object { $_.body } ) -join "`n---`n"
            } else { '' }
            Source           = 'Jira'
        }
    }
}