Private/Invoke-GitHubApi.ps1

function Invoke-GitHubApi {
    <#
    .SYNOPSIS
    Calls a GitHub REST endpoint.
 
    .DESCRIPTION
    The GitHub counterpart of Invoke-AzureDevOpsApi: every GitHub-facing command in this
    module goes through here so that URL building, authentication, pagination, rate-limit
    handling and error surfacing are written once.
 
    Authentication is a Bearer token: -Token when supplied, otherwise resolved by
    Get-GitHubAccessToken (the signed-in gh CLI first, then GITHUB_TOKEN) - ambient
    identity is the default and a stored token is the fallback, mirroring the Entra
    default on the Azure DevOps side. The token is never written to the URL, the log,
    or an exception message.
 
    GitHub pages list endpoints with an RFC-5988 'Link' response header rather than a
    continuation token; -AllPages follows rel="next" links and returns the aggregated
    array. Rate-limited responses (403/429 carrying Retry-After or an exhausted
    x-ratelimit-remaining) are retried after the server-indicated delay, capped at two
    minutes, up to three times.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Path,
        [ValidateSet('Get', 'Post', 'Patch', 'Put', 'Delete')] [string]$Method = 'Get',
        [hashtable]$Query,
        $Body,
        [string]$Token,

        # Follow 'Link: rel="next"' pagination and return the aggregated array.
        [switch]$AllPages,

        # Return $null on 404 instead of throwing, for existence probes.
        [switch]$AllowNotFound
    )

    if (-not $Token) { $Token = Get-GitHubAccessToken }

    $parameters = @{}
    if ($Query) { foreach ($key in $Query.Keys) { $parameters[$key] = $Query[$key] } }
    if ($AllPages -and -not $parameters.ContainsKey('per_page')) { $parameters['per_page'] = 100 }

    $uri = 'https://api.github.com/{0}' -f $Path.TrimStart('/')
    if ($parameters.Count) {
        $queryString = ($parameters.GetEnumerator() | ForEach-Object {
                '{0}={1}' -f $_.Key, [uri]::EscapeDataString([string]$_.Value)
            }) -join '&'
        $uri = '{0}?{1}' -f $uri, $queryString
    }

    $headers = @{
        Authorization          = 'Bearer ' + $Token
        Accept                 = 'application/vnd.github+json'
        'X-GitHub-Api-Version' = '2022-11-28'
        # GitHub rejects requests that carry no User-Agent.
        'User-Agent'           = 'NKDAgility.AzureDevOps.AutomationTools'
    }

    $bodyJson = $null
    if ($null -ne $Body) {
        $bodyJson = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 10 }
    }

    $results = @()
    $nextUri = $uri
    while ($nextUri) {
        $arguments = @{
            Uri         = $nextUri
            Method      = $Method
            Headers     = $headers
            ContentType = 'application/json'
            ErrorAction = 'Stop'
        }
        if ($bodyJson) { $arguments.Body = $bodyJson }

        $response = $null
        $responseHeaders = $null
        $attempt = 0
        while ($true) {
            $attempt++
            try {
                Write-DebugLog "GitHub REST {method} {uri}" -PropertyValues $Method, $nextUri
                $response = Invoke-RestMethod @arguments -ResponseHeadersVariable responseHeaders
                break
            }
            catch {
                $status = $null
                $exceptionResponse = $null
                if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
                    $exceptionResponse = $_.Exception.Response
                    $status = [int]$exceptionResponse.StatusCode
                }

                if ($status -eq 404 -and $AllowNotFound) { return $null }

                # Primary and secondary rate limits answer 403/429 with either a
                # Retry-After delay or an exhausted x-ratelimit-remaining plus the epoch
                # second the window resets. Anything else (a real permission denial) has
                # neither and is not retried.
                if ($attempt -le 3 -and ($status -eq 429 -or $status -eq 403)) {
                    $delay = Get-GitHubRetryDelay -Response $exceptionResponse
                    if ($delay -gt 0) {
                        Write-Warning ("GitHub rate limit hit; retrying in {0}s (attempt {1}/3)." -f $delay, $attempt)
                        Start-Sleep -Seconds $delay
                        continue
                    }
                }

                # ErrorDetails carries the GitHub error payload (message +
                # documentation_url), which is far more useful than the bare status line.
                $detail = if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $_.ErrorDetails.Message } else { $_.Exception.Message }
                throw "GitHub REST call failed: $Method $nextUri`n$detail"
            }
        }

        if (-not $AllPages) { return $response }

        $results += @($response)
        $nextUri = $null
        if ($responseHeaders) {
            # The header dictionary's key comparer is not guaranteed case-insensitive.
            $linkKey = @($responseHeaders.Keys | Where-Object { $_ -ieq 'Link' }) | Select-Object -First 1
            if ($linkKey) {
                $link = @($responseHeaders[$linkKey]) -join ', '
                if ($link -match '<([^>]+)>;\s*rel="next"') { $nextUri = $Matches[1] }
            }
        }
    }

    return $results
}