functions/github/Invoke-GitHubRestMethod.ps1

function Invoke-GitHubRestMethod
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$True)]
        [uri] $Uri,

        [Parameter()]
        [string] $Verb = 'GET',

        [Parameter()]
        [hashtable] $Body,

        [Parameter()]
        [string] $Token = $env:GITHUB_TOKEN,

        [Parameter()]
        [hashtable] $Headers = @{},

        [Parameter()]
        [int[]] $HttpErrorStatusCodesToIgnore = @(),

        [Parameter()]
        [switch] $AllPages,

        [Parameter()]
        [int] $MaxRetries = 5,

        [Parameter()]
        [float] $RetryBackOffBaseFactor = 1.5,

        [Parameter()]
        [int] $InitialBackOffSeconds = 60

    )

    if ($Headers.Keys -notcontains 'Accept') {
        $Headers += @{ Accept = 'application/vnd.github.machine-man-preview+json' }
    }
    if ($Headers.Keys -notcontains 'Authorization') {
        $Headers += @{ Authorization = "Token $Token" }
    }
    if ($Headers.Keys -notcontains 'Content-Type') {
        $Headers += @{ "Content-Type" = "application/json" }
    }

    # NOTE: We follow the pagination links ourselves rather than using '-FollowRelLink', as
    # Invoke-RestMethod does not send the 'Authorization' header when requesting subsequent
    # pages, which results in 401 errors or silently returns only publicly visible data
    # Results are buffered until all pages have been retrieved, so that a failure part-way
    # through does not return a truncated result set
    $results = [System.Collections.Generic.List[object]]::new()
    $nextUri = $Uri
    while ($nextUri) {
        $resp = $null
        $responseHeaders = $null
        $succeeded = $false
        $isRateLimited = $false
        $retryCount = 0
        do {
            try {
                $resp = Invoke-RestMethod -Headers $Headers  `
                                        -Method $Verb `
                                        -Uri $nextUri `
                                        -Body ($Body|ConvertTo-Json -Depth 100 -Compress) `
                                        -ResponseHeadersVariable responseHeaders

                $succeeded = $true
            }
            catch {
                if (
                        $_.Exception.Response.StatusCode -eq 429 -or `
                        ($_.Exception.Response.StatusCode -eq 403 -and $_.Exception.Message -match 'rate limit')
                ) {
                    # Handle rate limit errors as per GitHub's API guidelines
                    # ref: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28
                    # 1. Check for 429 or 403 with 'rate limit' in the error message
                    # 2. Check for 'Retry-After' header
                    # 3. Check for 'X-RateLimit-Remaining' = 0, wait until 'X-RateLimit-Reset' header
                    # 4. Wait for 60 seconds, expoenentially backing off for subsequent retries

                    $isRateLimited = $true
                    $retryAfter = $_.Exception.Response.Headers.Contains("Retry-After") ? ($_.Exception.Response.Headers.GetValues("Retry-After")[0]) : $null
                    if ($retryAfter) {
                        Write-Host "Rate limit exceeded. Waiting for $retryAfter seconds..."
                    }
                    elseif (
                        $_.Exception.Response.Headers.Contains('X-RateLimit-Remaining') -and `
                        $_.Exception.Response.Headers.GetValues('X-RateLimit-Remaining')[0] -eq 0 -and `
                        $_.Exception.Response.Headers.Contains('X-RateLimit-Reset')
                    ) {
                        $RateLimitReset = [datetime]::FromFileTimeUtc($_.Exception.Response.Headers.GetValues('X-RateLimit-Reset')[0])
                        $retryAfter = ($RateLimitReset - [datetime]::UtcNow).TotalSeconds
                        Write-Host "Rate limit exceeded. Waiting for quota reset in $retryAfter seconds..."
                    }
                    else {
                        # We have hit a secondary rate limit with no retry context
                        $retryAfter = [math]::Pow($RetryBackOffBaseFactor, $retryCount) * $InitialBackOffSeconds
                        Write-Host "Rate limit exceeded. Exponentional back-off, waiting for $retryAfter seconds..."
                    }
                    Start-Sleep -Seconds $retryAfter
                }
                elseif ($_.Exception.Response.StatusCode -notin $HttpErrorStatusCodesToIgnore) {
                    throw $_
                }
                else {
                    $isRateLimited = $false
                }

                $retryCount++
            }
        }
        while (!$succeeded -and $retryCount -le $MaxRetries)

        if (!$succeeded -and $isRateLimited) {
            throw "Rate limit still exceeded after $MaxRetries retries when requesting '$nextUri'"
        }

        # Flatten the result set in the event we traversed multiple pages
        foreach ($item in $resp) {
            $results.Add($item)
        }

        $nextUri = $null
        if ($AllPages -and $succeeded -and $responseHeaders -and $responseHeaders['Link']) {
            # e.g. Link: <https://api.github.com/...?page=2>; rel="next", <https://api.github.com/...?page=5>; rel="last"
            $nextMatch = [regex]::Match(($responseHeaders['Link'] -join ','), '<([^>]+)>;\s*rel="next"')
            if ($nextMatch.Success) {
                $nextUri = [uri]$nextMatch.Groups[1].Value

                # Only send our credentials to the origin that was originally requested
                # ref: https://github.com/PowerShell/PowerShell/issues/27861
                if (
                    $nextUri.Scheme -ne $Uri.Scheme -or `
                    $nextUri.Host -ne $Uri.Host -or `
                    $nextUri.Port -ne $Uri.Port
                ) {
                    throw "Refusing to follow pagination link to a different origin than the original request: '$($nextUri.GetLeftPart([UriPartial]::Authority))' (expected '$($Uri.GetLeftPart([UriPartial]::Authority))')"
                }
            }
        }
    }

    $results
}