internal/Invoke-MtGitHubRequest.ps1

function Invoke-MtGitHubRequest {
    <#
    .SYNOPSIS
    Internal: Authenticated read-only GET request to GitHub REST API with caching and pagination.

    .DESCRIPTION
    Uses Invoke-WebRequest for PowerShell 5.1 compatibility (Invoke-RestMethod
    -ResponseHeadersVariable is PowerShell 7+ only). Provides per-session caching,
    explicit opt-in pagination, and rate-limit detection.

    Cache key: ApiVersion|absoluteUri (cleared on reconnect and by Clear-ModuleVariable).
    Rate-limit detection: checks x-ratelimit-remaining in both success and error responses.

    .PARAMETER RelativeUri
    Path relative to ApiBaseUri. URL-encode path segments with [Uri]::EscapeDataString.

    .PARAMETER Paginate
    Follows Link header rel="next" and appends per_page=100. Use for list endpoints only.

    .PARAMETER DisableCache
    Bypasses session cache; makes a live API call.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $RelativeUri,
        [switch] $Paginate,
        [switch] $DisableCache
    )

    if ($null -eq $__MtSession.GitHubConnection -or
        $__MtSession.GitHubConnection.Connected -ne $true) {
        throw "Not connected to GitHub. Call Connect-MtGitHub first."
    }

    $baseUri = $__MtSession.GitHubConnection.ApiBaseUri
    $version = $__MtSession.GitHubConnection.ApiVersion
    $headers = $__MtSession.GitHubAuthHeader

    $absUri = "$baseUri/$($RelativeUri.TrimStart('/'))"
    if ($Paginate -and $absUri -notmatch '[?&]per_page=') {
        $sep = if ($absUri -match '\?') { '&' } else { '?' }
        $absUri = "${absUri}${sep}per_page=100"
    }

    $cacheKey = "$version|$absUri"
    if (-not $DisableCache -and $__MtSession.GitHubCache.ContainsKey($cacheKey)) {
        Write-Verbose "GitHub cache hit: $absUri"
        return $__MtSession.GitHubCache[$cacheKey]
    }

    function Invoke-Page ([string]$Uri) {
        try {
            $wr = Invoke-WebRequest -Uri $Uri -Headers $headers -Method GET -UseBasicParsing -ErrorAction Stop

            $body = if (-not [string]::IsNullOrWhiteSpace($wr.Content)) {
                # ConvertFrom-Json '[]' yields $null because it enumerates the (empty)
                # array, so pagination can't distinguish "no items" from "one null item"
                # without short-circuiting the empty-array case here.
                if ($wr.Content.Trim() -eq '[]') {
                    ,@()
                } else {
                    $wr.Content | ConvertFrom-Json
                }
            } else {
                $null
            }

            # Rate-limit warning on successful response — do NOT throw; the response body is valid.
            # TryParse rather than [int] cast: a malformed header (e.g. an upstream proxy
            # rewriting the value) must not raise a parse exception that masks a successful response.
            $remaining = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'x-ratelimit-remaining'
            $remainingValue = 0
            if ($null -ne $remaining -and [int]::TryParse([string]$remaining, [ref]$remainingValue) -and $remainingValue -eq 0) {
                $reset = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'x-ratelimit-reset'
                $resetValue = 0L
                $resetTime = 'unknown'
                if ($reset -and [long]::TryParse([string]$reset, [ref]$resetValue)) {
                    # FromUnixTimeSeconds throws ArgumentOutOfRangeException for values
                    # outside [-62135596800, 253402300799]. A bogus reset epoch must not
                    # mask the successful response — fall back to 'unknown' instead.
                    try {
                        $resetTime = [DateTimeOffset]::FromUnixTimeSeconds($resetValue).LocalDateTime
                    } catch {
                        $resetTime = 'unknown'
                    }
                }
                Write-Verbose "GitHub API rate limit remaining is 0 after this successful response. Resets at: $resetTime"
            }

            $linkHeader = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'Link'
            return [PSCustomObject]@{ Body = $body; Link = $linkHeader }
        } catch {
            $rateLimitMessage = Get-MtGitHubRateLimitMessage -ErrorRecord $_
            if ($rateLimitMessage) { throw $rateLimitMessage }
            throw
        }
    }

    function Get-NextLink ([string]$Link) {
        if ([string]::IsNullOrEmpty($Link)) { return $null }
        $m = [regex]::Match($Link, '<([^>]+)>;\s*rel="next"')
        if ($m.Success) { return $m.Groups[1].Value }
        return $null
    }

    # Refuse to follow a Link rel="next" that points outside the configured ApiBaseUri.
    # A malicious or buggy upstream that injects a foreign URL would otherwise receive
    # the Authorization header on a cross-origin request. Compare scheme + host + port
    # + base path prefix so GHE bases like https://host/api/v3 are honored.
    function Test-NextLinkSameOrigin ([string]$NextUri, [string]$BaseUri) {
        $baseParsed = $null
        $nextParsed = $null
        if (-not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref]$baseParsed)) { return $false }
        if (-not [uri]::TryCreate($NextUri, [UriKind]::Absolute, [ref]$nextParsed)) { return $false }
        if ($baseParsed.Scheme -ne $nextParsed.Scheme) { return $false }
        if (-not [string]::Equals($baseParsed.Host, $nextParsed.Host, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
        if ($baseParsed.Port -ne $nextParsed.Port) { return $false }
        $basePath = $baseParsed.AbsolutePath.TrimEnd('/')
        $nextPath = $nextParsed.AbsolutePath
        if ([string]::IsNullOrEmpty($basePath)) { return $true }
        return $nextPath -eq $basePath -or $nextPath.StartsWith("$basePath/", [System.StringComparison]::Ordinal)
    }

    $first = Invoke-Page $absUri

    if (-not $Paginate) {
        $result = $first.Body
    } else {
        $all = [System.Collections.Generic.List[object]]::new()
        # Filter $null per page so an empty-content or `[]` page does not contribute
        # spurious null items to the merged result.
        foreach ($item in @($first.Body)) {
            if ($null -ne $item) { $all.Add($item) }
        }
        $next = Get-NextLink $first.Link
        while ($null -ne $next) {
            if (-not (Test-NextLinkSameOrigin -NextUri $next -BaseUri $baseUri)) {
                throw "GitHub pagination refused: next link '$next' is outside the configured ApiBaseUri '$baseUri'."
            }
            $page = Invoke-Page $next
            foreach ($item in @($page.Body)) {
                if ($null -ne $item) { $all.Add($item) }
            }
            $next = Get-NextLink $page.Link
        }
        $result = $all.ToArray()
    }

    if (-not $DisableCache) { $__MtSession.GitHubCache[$cacheKey] = $result }
    return $result
}