Private/Invoke-GitHubApi.ps1
|
function Invoke-GitHubApi { [CmdletBinding()] [OutputType([PSCustomObject], [PSCustomObject[]])] param( [Parameter(Mandatory)] [string]$Endpoint, [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$Method = 'GET', [hashtable]$Body, [string]$Token = $env:GITHUB_TOKEN, [ValidateRange(1, 300)] [int]$TimeoutSec = 30, [switch]$GraphQL, [switch]$AllPages ) if (-not $Token) { throw 'GitHub token not provided. Use -Token or set GITHUB_TOKEN.' } # Trim whitespace and newlines — tokens pasted from web UIs often include line breaks $Token = $Token.Trim() if (-not $Token) { throw 'GitHub token is empty after trimming whitespace. Provide a valid token.' } if ($Token -match '[\x00-\x1F\x7F]') { throw 'GitHub token contains invalid control characters.' } $headers = @{ Authorization = "Bearer $Token" Accept = 'application/vnd.github+json' 'X-GitHub-Api-Version' = '2022-11-28' } if ($GraphQL) { $uri = 'https://api.github.com/graphql' $Method = 'POST' if (-not $Body) { $Body = @{ query = $Endpoint } } } else { if ($Endpoint -match '^https://') { $uri = $Endpoint } elseif ($Endpoint -match '^http://') { throw 'HTTP endpoints are not allowed. Use HTTPS only.' } else { $trimmedEndpoint = $Endpoint.TrimStart('/') $uri = "https://api.github.com/$trimmedEndpoint" } } $maxPages = 100 $pageCount = 0 $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() $nextUri = $uri do { $pageCount++ if ($pageCount -gt $maxPages) { Write-Warning "Pagination limit reached ($maxPages pages). Results may be incomplete." break } $invokeParams = @{ Uri = $nextUri Method = $Method Headers = $headers ErrorAction = 'Stop' ResponseHeadersVariable = 'responseHeaders' TimeoutSec = $TimeoutSec SkipHeaderValidation = $true } if ($Body) { $invokeParams['ContentType'] = 'application/json' $invokeParams['Body'] = ($Body | ConvertTo-Json -Depth 20) } try { $response = Invoke-RestMethod @invokeParams $remaining = $null if ($responseHeaders.ContainsKey('X-RateLimit-Remaining')) { $remaining = [int]($responseHeaders['X-RateLimit-Remaining'][0]) } elseif ($responseHeaders.ContainsKey('x-ratelimit-remaining')) { $remaining = [int]($responseHeaders['x-ratelimit-remaining'][0]) } if ($null -ne $remaining -and $remaining -le 10) { $resetEpoch = $null if ($responseHeaders.ContainsKey('X-RateLimit-Reset')) { $resetEpoch = [long]($responseHeaders['X-RateLimit-Reset'][0]) } elseif ($responseHeaders.ContainsKey('x-ratelimit-reset')) { $resetEpoch = [long]($responseHeaders['x-ratelimit-reset'][0]) } if ($remaining -eq 0 -and $null -ne $resetEpoch) { $resetTime = [DateTimeOffset]::FromUnixTimeSeconds($resetEpoch).UtcDateTime throw "GitHub API rate limit exhausted. Resets at $resetTime UTC." } Write-Warning "GitHub API rate limit is low: $remaining requests remaining." } } catch { $errorMessage = $_.Exception.Message # Strip any token values that .NET may include in validation error messages if ($Token.Length -gt 8) { $errorMessage = $errorMessage -replace [regex]::Escape($Token), '***' } if ($_.ErrorDetails.Message) { try { $ghError = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue if ($ghError.message) { $errorMessage = "$errorMessage`nGitHub response: $($ghError.message)" } } catch { Write-Debug "Could not parse GitHub error response as JSON: $($_.Exception.Message)" } } # Sanitize: mask any access_token value while preserving the original query string structure $sanitizedUri = $nextUri if ($sanitizedUri -match '(?:\?|&)access_token=') { try { $uriBuilder = [System.UriBuilder]$sanitizedUri $query = [System.Web.HttpUtility]::ParseQueryString($uriBuilder.Query) if ($null -ne $query['access_token']) { $query['access_token'] = '***' $uriBuilder.Query = $query.ToString() $sanitizedUri = $uriBuilder.Uri.AbsoluteUri } } catch { $sanitizedUri = $sanitizedUri -replace '([?&]access_token=)[^&]+', '$1***' } } throw "GitHub API call failed for '$sanitizedUri' using method '$Method'. $errorMessage" } if ($AllPages) { if ($response -is [System.Array]) { foreach ($item in $response) { $allResults.Add($item) } } else { $allResults.Add($response) } # Parse Link header for next page $nextUri = $null $linkHeader = $null if ($responseHeaders.ContainsKey('Link')) { $linkHeader = $responseHeaders['Link'][0] } elseif ($responseHeaders.ContainsKey('link')) { $linkHeader = $responseHeaders['link'][0] } if ($linkHeader -and $linkHeader -match '<([^>]+)>;\s*rel="next"') { $nextUri = $Matches[1] } } else { return $response } } while ($null -ne $nextUri) return $allResults.ToArray() } |