Private/Invoke-AvmGitHubRequest.ps1

function Get-AvmGitHubAuthHeaders {
    <#
    .SYNOPSIS
        Builds HTTP headers for GitHub API/raw-content requests.
    .DESCRIPTION
        Always sets a User-Agent (required by the GitHub API). When $env:GITHUB_TOKEN or
        $env:GH_TOKEN is set (GITHUB_TOKEN takes precedence), adds an
        'Authorization: Bearer <token>' header so requests count against the authenticated
        rate limit (5,000/hour) instead of the unauthenticated one (60/hour). The token value
        itself is never written to output, verbose, or warning streams.
    .OUTPUTS
        Hashtable of headers.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    $headers = @{ 'User-Agent' = 'AvmUpdater' }
    $token = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { $null }
    if ($token) {
        $headers['Authorization'] = "Bearer $token"
    }
    return $headers
}

function Invoke-AvmGitHubRequest {
    <#
    .SYNOPSIS
        Fetches a URL from GitHub (raw content or REST API) with auth headers, an in-memory
        content cache, and a single rate-limit warning per run.
    .DESCRIPTION
        Wraps Invoke-RestMethod for all GitHub requests made by Get-AvmInterfaceDiff and
        Get-AvmChangelog:
        - Sends an Authorization: Bearer header when $env:GITHUB_TOKEN/$env:GH_TOKEN is set
          (see Get-AvmGitHubAuthHeaders). Never logs the token.
        - Caches successful responses in $script:_AvmGitHubContentCache keyed by the request
          URI (which already encodes repo path + version/ref), so the same module referenced
          from many files in a scan is only fetched once — mirrors the pattern of
          $script:_AvmVersionCache in Get-AvmLatestVersion.
        - On failure, never throws. Returns $null so callers treat it the same as
          "content unavailable". If the failure looks like a GitHub rate-limit response, emits
          ONE Write-Warning for the whole run (tracked via $script:_AvmGitHubRateLimitWarned)
          instead of one per module/call.
    .PARAMETER Uri
        The URL to fetch (raw.githubusercontent.com or api.github.com).
    .PARAMETER TimeoutSec
        Per-request timeout in seconds. Defaults to 15.
    .OUTPUTS
        The response body (string or deserialized object), or $null on failure.
    #>

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

        [Parameter()]
        [int]$TimeoutSec = 15
    )

    if ($script:_AvmGitHubContentCache.ContainsKey($Uri)) {
        Write-Verbose "GitHub content cache hit: $Uri"
        return $script:_AvmGitHubContentCache[$Uri]
    }

    $headers = Get-AvmGitHubAuthHeaders

    try {
        $content = Invoke-RestMethod -Uri $Uri -Headers $headers -TimeoutSec $TimeoutSec -ErrorAction Stop
    } catch {
        if (Test-AvmGitHubRateLimitError -ErrorRecord $_) {
            if (-not $script:_AvmGitHubRateLimitWarned) {
                Write-Warning 'GitHub rate limit hit — set GITHUB_TOKEN to raise the limit; risk tiers may degrade to UNKNOWN'
                $script:_AvmGitHubRateLimitWarned = $true
            }
        } else {
            Write-Verbose "GitHub request failed for ${Uri}: $_"
        }
        return $null
    }

    $script:_AvmGitHubContentCache[$Uri] = $content
    return $content
}

function Test-AvmGitHubRateLimitError {
    <#
    .SYNOPSIS
        Determines whether a caught error from Invoke-RestMethod represents a GitHub
        rate-limit (403) response.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$ErrorRecord)

    $statusCode = $null
    try {
        $statusCode = [int]$ErrorRecord.Exception.Response.StatusCode
    } catch {
        # Response/StatusCode may not exist on this exception type — fall back to $null
        # and rely on the message-based checks below.
        $statusCode = $null
    }

    $exceptionMessage = try { $ErrorRecord.Exception.Message } catch { $null }
    $errorDetailsMessage = try { $ErrorRecord.ErrorDetails.Message } catch { $null }
    $message = "$exceptionMessage $errorDetailsMessage"

    if ($message -match '(?i)rate limit') { return $true }
    if ($statusCode -eq 403 -and $message -match '(?i)forbidden|limit') { return $true }

    return $false
}